[
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\ncharset=utf-8\nend_of_line=lf\ninsert_final_newline=true\ntrim_trailing_whitespace=true\nindent_size=2\nindent_style=space\n\n[*.go]\nindent_size=4\nindent_style=tab\n"
  },
  {
    "path": ".github/CODEOWNERS",
    "content": "*  @aymanbagabas\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**To Reproduce**\nSteps to reproduce the behavior:\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**Environment (please complete the following information):**\n - OS: [e.g. Linux]\n - Terminal [e.g. kitty, iterm2, gnome-terminal]\n - Version [e.g. v0.4.0]\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\n\nupdates:\n  - package-ecosystem: \"gomod\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n      day: \"monday\"\n      time: \"05:00\"\n      timezone: \"America/New_York\"\n    labels:\n      - \"dependencies\"\n    commit-message:\n      prefix: \"chore\"\n      include: \"scope\"\n    groups:\n      all:\n        patterns:\n          - \"*\"\n    ignore:\n      - dependency-name: github.com/charmbracelet/bubbletea/v2\n        versions:\n          - v2.0.0-beta1\n\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n      day: \"monday\"\n      time: \"05:00\"\n      timezone: \"America/New_York\"\n    labels:\n      - \"dependencies\"\n    commit-message:\n      prefix: \"chore\"\n      include: \"scope\"\n    groups:\n      all:\n        patterns:\n          - \"*\"\n\n  - package-ecosystem: \"docker\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n      day: \"monday\"\n      time: \"05:00\"\n      timezone: \"America/New_York\"\n    labels:\n      - \"dependencies\"\n    commit-message:\n      prefix: \"chore\"\n      include: \"scope\"\n    groups:\n      all:\n        patterns:\n          - \"*\"\n"
  },
  {
    "path": ".github/workflows/build.yml",
    "content": "name: build\n\non:\n  push:\n    branches:\n      - \"main\"\n  pull_request:\n\njobs:\n  build:\n    uses: charmbracelet/meta/.github/workflows/build.yml@main\n\n  snapshot:\n    uses: charmbracelet/meta/.github/workflows/snapshot.yml@main\n    secrets:\n      goreleaser_key: ${{ secrets.GORELEASER_KEY }}\n\n  test_postgres:\n    services:\n      postgres:\n        image: postgres\n        ports:\n          - 5432:5432\n        env:\n          POSTGRES_PASSWORD: postgres\n        options: >-\n          --health-cmd pg_isready\n          --health-interval 10s\n          --health-timeout 5s\n          --health-retries 5\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n      - name: Install Go\n        uses: actions/setup-go@v6\n        with:\n          go-version: ^1\n          cache: true\n      - name: Download Go modules\n        run: go mod download\n      - name: Test\n        run: go test ./...\n        env:\n          SOFT_SERVE_DB_DRIVER: postgres\n          SOFT_SERVE_DB_DATA_SOURCE: postgres://postgres:postgres@localhost/postgres?sslmode=disable\n"
  },
  {
    "path": ".github/workflows/coverage.yml",
    "content": "name: coverage\n\non:\n  push:\n    branches:\n      - \"main\"\n  pull_request:\n\njobs:\n  coverage:\n    strategy:\n      matrix:\n        os: [ubuntu-latest] # TODO: add macos & windows\n    services:\n      postgres:\n        image: postgres\n        ports:\n          - 5432:5432\n        env:\n          POSTGRES_PASSWORD: postgres\n        options: >-\n          --health-cmd pg_isready\n          --health-interval 10s\n          --health-timeout 5s\n          --health-retries 5\n    runs-on: ${{ matrix.os }}\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Set up Go\n        uses: actions/setup-go@v6\n        with:\n          go-version: ^1\n\n      - name: Test\n        run: |\n          # We collect coverage data from two sources,\n          # 1) unit tests 2) integration tests\n          #\n          # https://go.dev/testing/coverage/\n          # https://dustinspecker.com/posts/go-combined-unit-integration-code-coverage/\n          # https://github.com/golang/go/issues/51430#issuecomment-1344711300\n          mkdir -p coverage/unit\n          mkdir -p coverage/int\n          mkdir -p coverage/int2\n\n          # Collect unit tests coverage\n          go test -failfast -race -timeout 5m -skip=^TestScript -cover ./... -args -test.gocoverdir=$PWD/coverage/unit\n\n          # Collect integration tests coverage\n          GOCOVERDIR=$PWD/coverage/int go test -failfast -race -timeout 5m -run=^TestScript ./...\n          SOFT_SERVE_DB_DRIVER=postgres \\\n            SOFT_SERVE_DB_DATA_SOURCE=postgres://postgres:postgres@localhost/postgres?sslmode=disable \\\n            GOCOVERDIR=$PWD/coverage/int2 go test -failfast -race -timeout 5m -run=^TestScript ./...\n\n          # Convert coverage data to legacy textfmt format to upload\n          go tool covdata textfmt -i=coverage/unit,coverage/int,coverage/int2 -o=coverage.txt\n      - uses: codecov/codecov-action@v5\n        with:\n          file: ./coverage.txt\n"
  },
  {
    "path": ".github/workflows/dependabot-sync.yml",
    "content": "name: dependabot-sync\non:\n  schedule:\n    - cron: \"0 0 * * 0\" # every Sunday at midnight\n  workflow_dispatch: # allows manual triggering\n\npermissions:\n  contents: write\n  pull-requests: write\n\njobs:\n  dependabot-sync:\n    uses: charmbracelet/meta/.github/workflows/dependabot-sync.yml@main\n    with:\n      repo_name: ${{ github.event.repository.name }}\n    secrets:\n      gh_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/goreleaser.yml",
    "content": "# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json\nname: goreleaser\n\non:\n  push:\n    tags:\n      - v*.*.*\n\nconcurrency:\n  group: goreleaser\n  cancel-in-progress: true\n\njobs:\n  goreleaser:\n    uses: charmbracelet/meta/.github/workflows/goreleaser.yml@main\n    secrets:\n      docker_username: ${{ secrets.DOCKERHUB_USERNAME }}\n      docker_token: ${{ secrets.DOCKERHUB_TOKEN }}\n      gh_pat: ${{ secrets.PERSONAL_ACCESS_TOKEN }}\n      goreleaser_key: ${{ secrets.GORELEASER_KEY }}\n      fury_token: ${{ secrets.FURY_TOKEN }}\n      nfpm_gpg_key: ${{ secrets.NFPM_GPG_KEY }}\n      nfpm_passphrase: ${{ secrets.NFPM_PASSPHRASE }}\n      macos_sign_p12: ${{ secrets.MACOS_SIGN_P12 }}\n      macos_sign_password: ${{ secrets.MACOS_SIGN_PASSWORD }}\n      macos_notary_issuer_id: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}\n      macos_notary_key_id: ${{ secrets.MACOS_NOTARY_KEY_ID }}\n      macos_notary_key: ${{ secrets.MACOS_NOTARY_KEY }}\n"
  },
  {
    "path": ".github/workflows/lint-sync.yml",
    "content": "name: lint-sync\non:\n  # schedule:\n  #   # every Sunday at midnight\n  #   - cron: \"0 0 * * 0\"\n  workflow_dispatch: # allows manual triggering\n\npermissions:\n  contents: write\n  pull-requests: write\n\njobs:\n  lint:\n    uses: charmbracelet/meta/.github/workflows/lint-sync.yml@main\n"
  },
  {
    "path": ".github/workflows/lint.yml",
    "content": "name: lint\non:\n  push:\n  pull_request:\n\njobs:\n  lint:\n    uses: charmbracelet/meta/.github/workflows/lint.yml@main\n    with:\n      golangci_path: .golangci.yml\n      golangci_version: latest\n      timeout: 10m\n"
  },
  {
    "path": ".github/workflows/nightly.yml",
    "content": "name: nightly\n\non:\n  push:\n    branches:\n      - main\n\njobs:\n  nightly:\n    uses: charmbracelet/meta/.github/workflows/nightly.yml@main\n    secrets:\n      docker_username: ${{ secrets.DOCKERHUB_USERNAME }}\n      docker_token: ${{ secrets.DOCKERHUB_TOKEN }}\n      goreleaser_key: ${{ secrets.GORELEASER_KEY }}\n      macos_sign_p12: ${{ secrets.MACOS_SIGN_P12 }}\n      macos_sign_password: ${{ secrets.MACOS_SIGN_PASSWORD }}\n      macos_notary_issuer_id: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}\n      macos_notary_key_id: ${{ secrets.MACOS_NOTARY_KEY_ID }}\n      macos_notary_key: ${{ secrets.MACOS_NOTARY_KEY }}\n"
  },
  {
    "path": ".gitignore",
    "content": "cmd/soft/soft\n./soft\n.ssh\n.repos\ndist\ndata/\ncompletions/\nmanpages/\nsoft_serve_ed25519*\n"
  },
  {
    "path": ".golangci.yml",
    "content": "version: \"2\"\nlinters:\n  enable:\n    - bodyclose\n    # - exhaustive\n    # - goconst\n    # - godot\n    # - godox\n    # - gomoddirectives\n    - goprintffuncname\n    # - gosec\n    - misspell\n    # - nakedret\n    # - nestif\n    # - nilerr\n    - noctx\n    - nolintlint\n    # - prealloc\n    # - revive\n    - rowserrcheck\n    - sqlclosecheck\n    - tparallel\n    # - unconvert\n    # - unparam\n    - whitespace\n    # - wrapcheck\n  disable:\n    - errcheck\n    - ineffassign\n    - unused\n    - staticcheck\n  exclusions:\n    generated: lax\n    presets:\n      - common-false-positives\n    rules:\n      - text: '(slog|log)\\.\\w+'\n        linters:\n          - noctx\nissues:\n  max-issues-per-linter: 0\n  max-same-issues: 0\nformatters:\n  enable:\n    - gofumpt\n    - goimports\n  exclusions:\n    generated: lax\n"
  },
  {
    "path": ".goreleaser.yml",
    "content": "# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json\n\nversion: 2\n\nincludes:\n  - from_url:\n      url: charmbracelet/meta/main/goreleaser-soft-serve.yaml\n\nvariables:\n  main: \"./cmd/soft\"\n  binary_name: soft\n  description: \"A tasty, self-hostable Git server for the command line🍦\"\n  github_url: \"https://github.com/charmbracelet/soft-serve\"\n  maintainer: \"Ayman Bagabas <ayman@charm.sh>\"\n  brew_commit_author_name: \"Ayman Bagabas\"\n  brew_commit_author_email: \"ayman@charm.sh\"\n"
  },
  {
    "path": ".nfpm/postinstall.sh",
    "content": "#!/bin/sh\nset -e\n\nif ! command -V systemctl >/dev/null 2>&1; then\n\techo \"Not running SystemD, ignoring\"\n\texit 0\nfi\n\nsystemd-sysusers\nsystemd-tmpfiles --create\n\nsystemctl daemon-reload\nsystemctl unmask soft-serve.service\nsystemctl preset soft-serve.service\n"
  },
  {
    "path": ".nfpm/postremove.sh",
    "content": "#!/bin/sh\nset -e\n\nif ! command -V systemctl >/dev/null 2>&1; then\n\techo \"Not running SystemD, ignoring\"\n\texit 0\nfi\n\nsystemctl daemon-reload\nsystemctl reset-failed\n\necho \"WARN: the soft-serve user/group and /var/lib/soft-serve directory were not removed\"\n"
  },
  {
    "path": ".nfpm/soft-serve.conf",
    "content": "# Config defined here will override the config in /var/lib/soft-serve/config.yaml\n# Keys defined in `SOFT_SERVE_INITIAL_ADMIN_KEYS` will be merged with \n# the `initial_admin_keys` from /var/lib/soft-serve/config.yaml.\n#\n#SOFT_SERVE_GIT_LISTEN_ADDR=:9418\n#SOFT_SERVE_HTTP_LISTEN_ADDR=:23232\n#SOFT_SERVE_SSH_LISTEN_ADDR=:23231\n#SOFT_SERVE_SSH_KEY_PATH=ssh/soft_serve_host_ed25519\n#SOFT_SERVE_INITIAL_ADMIN_KEYS='ssh-ed25519 AAAAC3NzaC1lZDI1...'\n"
  },
  {
    "path": ".nfpm/soft-serve.service",
    "content": "[Unit]\nDescription=Soft Serve git server 🍦\nDocumentation=https://github.com/charmbracelet/soft-serve\nRequires=network-online.target\nAfter=network-online.target\n\n[Service]\nType=simple\nUser=soft-serve\nGroup=soft-serve\nRestart=always\nRestartSec=1\nExecStart=/usr/bin/soft serve\nEnvironment=SOFT_SERVE_DATA_PATH=/var/lib/soft-serve\nEnvironmentFile=-/etc/soft-serve.conf\nWorkingDirectory=/var/lib/soft-serve\n\n# Hardening\nReadWritePaths=/var/lib/soft-serve\nUMask=0027\nNoNewPrivileges=true\nLimitNOFILE=1048576\nProtectSystem=strict\nProtectHome=true\nPrivateUsers=yes\nPrivateTmp=true\nPrivateDevices=true\nProtectHostname=true\nProtectClock=true\nProtectKernelTunables=true\nProtectKernelModules=true\nProtectKernelLogs=true\nProtectControlGroups=true\nRestrictAddressFamilies=AF_UNIX AF_INET AF_INET6\nRestrictNamespaces=true\nLockPersonality=true\nMemoryDenyWriteExecute=true\nRestrictRealtime=true\nRestrictSUIDSGID=true\nRemoveIPC=true\nCapabilityBoundingSet=\nAmbientCapabilities=\nSystemCallFilter=@system-service\nSystemCallFilter=~@privileged @resources\nSystemCallArchitectures=native\n\n[Install]\nWantedBy=multi-user.target\n"
  },
  {
    "path": ".nfpm/sysusers.conf",
    "content": "u soft-serve - \"Soft Serve daemon user\" /var/lib/soft-serve\n"
  },
  {
    "path": ".nfpm/tmpfiles.conf",
    "content": "d /var/lib/soft-serve 0750 soft-serve soft-serve\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM alpine:latest\n\n# Create directories\nWORKDIR /soft-serve\n# Expose data volume\nVOLUME /soft-serve\n\n# Environment variables\nENV SOFT_SERVE_DATA_PATH \"/soft-serve\"\nENV SOFT_SERVE_INITIAL_ADMIN_KEYS \"\"\n# workaround to prevent slowness in docker when running with a tty\nENV CI \"1\"\n\n# Expose ports\n# SSH\nEXPOSE 23231/tcp\n# HTTP\nEXPOSE 23232/tcp\n# Stats\nEXPOSE 23233/tcp\n# Git\nEXPOSE 9418/tcp\n\n# Set the default command\nENTRYPOINT [ \"/usr/local/bin/soft\", \"serve\" ]\n\nRUN apk update && apk add --update git bash openssh && rm -rf /var/cache/apk/*\n\nCOPY soft /usr/local/bin/soft\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2021-2023 Charmbracelet, Inc\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# Soft Serve\n\n<p>\n    <img style=\"width: 451px\" src=\"https://stuff.charm.sh/soft-serve/soft-serve-header.png?0\" alt=\"A nice rendering of some melting ice cream with the words ‘Charm Soft Serve’ next to it\"><br>\n    <a href=\"https://github.com/charmbracelet/soft-serve/releases\"><img src=\"https://img.shields.io/github/release/charmbracelet/soft-serve.svg\" alt=\"Latest Release\"></a>\n    <a href=\"https://pkg.go.dev/github.com/charmbracelet/soft-serve?tab=doc\"><img src=\"https://godoc.org/github.com/golang/gddo?status.svg\" alt=\"GoDoc\"></a>\n    <a href=\"https://github.com/charmbracelet/soft-serve/actions\"><img src=\"https://github.com/charmbracelet/soft-serve/workflows/build/badge.svg\" alt=\"Build Status\"></a>\n    <a href=\"https://nightly.link/charmbracelet/soft-serve/workflows/nightly/main\"><img src=\"https://shields.io/badge/-Nightly%20Builds-orange?logo=hackthebox&logoColor=fff&style=appveyor\"/></a>\n</p>\n\nA tasty, self-hostable Git server for the command line. 🍦\n\n<picture>\n  <source media=\"(max-width: 750px)\" srcset=\"https://github.com/charmbracelet/soft-serve/assets/42545625/c754c746-dc4c-44a6-9c39-28649264cbf2\">\n  <source media=\"(min-width: 750px)\" width=\"750\" srcset=\"https://github.com/charmbracelet/soft-serve/assets/42545625/c754c746-dc4c-44a6-9c39-28649264cbf2\">\n  <img src=\"https://github.com/charmbracelet/soft-serve/assets/42545625/c754c746-dc4c-44a6-9c39-28649264cbf2\" alt=\"Soft Serve screencast\">\n</picture>\n\n- Easy to navigate TUI available over SSH\n- Clone repos over SSH, HTTP, or Git protocol\n- Git LFS support with both HTTP and SSH backends\n- Manage repos with SSH\n- Create repos on demand with SSH or `git push`\n- Browse repos, files and commits with SSH-accessible UI\n- Print files over SSH with or without syntax highlighting and line numbers\n- Easy access control\n  - SSH authentication using public keys\n  - Allow/disallow anonymous access\n  - Add collaborators with SSH public keys\n  - Repos can be public or private\n  - User access tokens\n\n## Where can I see it?\n\nJust run `ssh git.charm.sh` for an example. You can also try some of the following commands:\n\n```bash\n# Jump directly to a repo in the TUI\nssh git.charm.sh -t soft-serve\n\n# Print out a directory tree for a repo\nssh git.charm.sh repo tree soft-serve\n\n# Print a specific file\nssh git.charm.sh repo blob soft-serve cmd/soft/main.go\n\n# Print a file with syntax highlighting and line numbers\nssh git.charm.sh repo blob soft-serve cmd/soft/main.go -c -l\n```\n\nOr you can use Soft Serve to browse local repositories using `soft browse\n[directory]` or running `soft` within a Git repository.\n\n## Installation\n\nSoft Serve is a single binary called `soft`. You can get it from a package\nmanager:\n\n```bash\n# macOS or Linux\nbrew install charmbracelet/tap/soft-serve\n\n# Windows (with Winget)\nwinget install charmbracelet.soft-serve\n\n# Arch Linux\npacman -S soft-serve\n\n# Nix\nnix-env -iA nixpkgs.soft-serve\n\n# Debian/Ubuntu\nsudo mkdir -p /etc/apt/keyrings\ncurl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/charm.gpg\necho \"deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *\" | sudo tee /etc/apt/sources.list.d/charm.list\nsudo apt update && sudo apt install soft-serve\n\n# Fedora/RHEL\necho '[charm]\nname=Charm\nbaseurl=https://repo.charm.sh/yum/\nenabled=1\ngpgcheck=1\ngpgkey=https://repo.charm.sh/yum/gpg.key' | sudo tee /etc/yum.repos.d/charm.repo\nsudo yum install soft-serve\n```\n\nYou can also download a binary from the [releases][releases] page. Packages are\navailable in Alpine, Debian, and RPM formats. Binaries are available for Linux,\nmacOS, and Windows.\n\n[releases]: https://github.com/charmbracelet/soft-serve/releases\n\nOr just install it with `go`:\n\n```bash\ngo install github.com/charmbracelet/soft-serve/cmd/soft@latest\n```\n\nA [Docker image][docker] is also available.\n\n[docker]: https://github.com/charmbracelet/soft-serve/blob/main/docker.md\n\n## Setting up a server\n\nMake sure `git` is installed, then run `soft serve`. That’s it.\n\nThis will create a `data` directory that will store all the repos, ssh keys,\nand database.\n\nBy default, program configuration is stored within the `data` directory. But,\nthis can be overridden by setting a custom path to a config file with `SOFT_SERVE_CONFIG_LOCATION`\nthat is pre-created. If a config file pointed to by `SOFT_SERVE_CONFIG_LOCATION`,\nthe default location within the `data` dir is used for generating a default config.\n\nTo change the default data path use `SOFT_SERVE_DATA_PATH` environment variable.\n\n```sh\nSOFT_SERVE_DATA_PATH=/var/lib/soft-serve soft serve\n```\n\nWhen you run Soft Serve for the first time, make sure you have the\n`SOFT_SERVE_INITIAL_ADMIN_KEYS` environment variable is set to your ssh\nauthorized key. Any added key to this variable will be treated as admin with\nfull privileges.\n\nUsing this environment variable, Soft Serve will create a new `admin` user that\nhas full privileges. You can rename and change the user settings later.\n\nCheck out [Systemd][systemd] on how to run Soft Serve as a service using\nSystemd. Soft Serve packages in our Apt/Yum repositories come with Systemd\nservice units.\n\n[systemd]: https://github.com/charmbracelet/soft-serve/blob/main/systemd.md\n\n### Server Configuration\n\nOnce you start the server for the first time, the settings will be in\n`config.yaml` under your data directory. The default `config.yaml` is\nself-explanatory and will look like this:\n\n```yaml\n# Soft Serve Server configurations\n\n# The name of the server.\n# This is the name that will be displayed in the UI.\nname: \"Soft Serve\"\n\n# Log format to use. Valid values are \"json\", \"logfmt\", and \"text\".\nlog_format: \"text\"\n\n# The SSH server configuration.\nssh:\n  # The address on which the SSH server will listen.\n  listen_addr: \":23231\"\n\n  # The public URL of the SSH server.\n  # This is the address that will be used to clone repositories.\n  public_url: \"ssh://localhost:23231\"\n\n  # The path to the SSH server's private key.\n  key_path: \"ssh/soft_serve_host\"\n\n  # The path to the SSH server's client private key.\n  # This key will be used to authenticate the server to make git requests to\n  # ssh remotes.\n  client_key_path: \"ssh/soft_serve_client\"\n\n  # The maximum number of seconds a connection can take.\n  # A value of 0 means no timeout.\n  max_timeout: 0\n\n  # The number of seconds a connection can be idle before it is closed.\n  idle_timeout: 120\n\n# The Git daemon configuration.\ngit:\n  # The address on which the Git daemon will listen.\n  listen_addr: \":9418\"\n\n  # The maximum number of seconds a connection can take.\n  # A value of 0 means no timeout.\n  max_timeout: 0\n\n  # The number of seconds a connection can be idle before it is closed.\n  idle_timeout: 3\n\n  # The maximum number of concurrent connections.\n  max_connections: 32\n\n# The HTTP server configuration.\nhttp:\n  # The address on which the HTTP server will listen.\n  listen_addr: \":23232\"\n\n  # The path to the TLS private key.\n  tls_key_path: \"\"\n\n  # The path to the TLS certificate.\n  tls_cert_path: \"\"\n\n  # The public URL of the HTTP server.\n  # This is the address that will be used to clone repositories.\n  # Make sure to use https:// if you are using TLS.\n  public_url: \"http://localhost:23232\"\n\n  # The cross-origin request security options\n  cors:\n    # The allowed cross-origin headers\n    allowed_headers:\n      - \"Accept\"\n      - \"Accept-Language\"\n      - \"Content-Language\"\n      - \"Content-Type\"\n      - \"Origin\"\n      - \"X-Requested-With\"\n      - \"User-Agent\"\n      - \"Authorization\"\n      - \"Access-Control-Request-Method\"\n      - \"Access-Control-Allow-Origin\"\n\n    # The allowed cross-origin URLs\n    allowed_origins:\n      - \"http://localhost:23232\" # always allowed\n      # - \"https://example.com\"\n\n    # The allowed cross-origin methods\n    allowed_methods:\n      - \"GET\"\n      - \"HEAD\"\n      - \"POST\"\n      - \"PUT\"\n      - \"OPTIONS\"\n\n# The database configuration.\ndb:\n  # The database driver to use.\n  # Valid values are \"sqlite\" and \"postgres\".\n  driver: \"sqlite\"\n  # The database data source name.\n  # This is driver specific and can be a file path or connection string.\n  # Make sure foreign key support is enabled when using SQLite.\n  data_source: \"soft-serve.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)\"\n\n# Git LFS configuration.\nlfs:\n  # Enable Git LFS.\n  enabled: true\n  # Enable Git SSH transfer.\n  ssh_enabled: false\n\n# Cron job configuration\njobs:\n  mirror_pull: \"@every 10m\"\n\n# The stats server configuration.\nstats:\n  # The address on which the stats server will listen.\n  listen_addr: \":23233\"\n# Additional admin keys.\n#initial_admin_keys:\n#  - \"ssh-rsa AAAAB3NzaC1yc2...\"\n```\n\nYou can also use environment variables, to override these settings. All server\nsettings environment variables start with `SOFT_SERVE_` followed by the setting\nname all in uppercase. Here are some examples:\n\n- `SOFT_SERVE_NAME`: The name of the server that will appear in the TUI\n- `SOFT_SERVE_SSH_LISTEN_ADDR`: SSH listen address\n- `SOFT_SERVE_SSH_KEY_PATH`: SSH host key-pair path\n- `SOFT_SERVE_HTTP_LISTEN_ADDR`: HTTP listen address\n- `SOFT_SERVE_HTTP_PUBLIC_URL`: HTTP public URL used for cloning\n- `SOFT_SERVE_GIT_MAX_CONNECTIONS`: The number of simultaneous connections to git daemon\n\n#### Database Configuration\n\nSoft Serve supports both SQLite and Postgres for its database. Like all other Soft Serve settings, you can change the database _driver_ and _data source_ using either `config.yaml` or environment variables. The default config uses SQLite as the default database driver.\n\nTo use Postgres as your database, first create a Soft Serve database:\n\n```sh\npsql -h<hostname> -p<port> -U<user> -c 'CREATE DATABASE soft_serve'\n```\n\nThen set the database _data source_ to point to your Postgres database. For instance, if you're running Postgres locally, using the default user `postgres` and using a database name `soft_serve`, you would have this config in your config file or environment variable:\n\n```\ndb:\n  driver: \"postgres\"\n  data_source: \"postgres://postgres@localhost:5432/soft_serve?sslmode=disable\"\n```\n\nEnvironment variables equivalent:\n\n```sh\nSOFT_SERVE_DB_DRIVER=postgres \\\nSOFT_SERVE_DB_DATA_SOURCE=\"postgres://postgres@localhost:5432/soft_serve?sslmode=disable\" \\\nsoft serve\n```\n\nYou can specify a database connection password in the _data source_ url. For example, `postgres://myuser:dbpass@localhost:5432/my_soft_serve_db`.\n\n#### LFS Configuration\n\nSoft Serve supports both Git LFS [HTTP](https://github.com/git-lfs/git-lfs/blob/main/docs/api/README.md) and [SSH](https://github.com/git-lfs/git-lfs/blob/main/docs/proposals/ssh_adapter.md) protocols out of the box, there is no need to do any extra set up.\n\nUse the `lfs` config section to customize your Git LFS server.\n\n> **Note**: The pure-SSH transfer is disabled by default.\n\n## Server Access\n\nSoft Serve at its core manages your server authentication and authorization. Authentication verifies the identity of a user, while authorization determines their access rights to a repository.\n\nTo manage the server users, access, and repos, you can use the SSH command line interface.\n\nTry `ssh localhost -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes -p 23231 help` for more info. Make sure\nyou use your key here.\n\n> **Note** The `IdentitiesOnly` option is used to prevent SSH from using any\n> other keys in your `~/.ssh` directory. This is useful when you have multiple\n> keys, and you want to use a specific key for Soft Serve.\n\nFor ease of use, instead of specifying the key, port, and hostname every time\nyou SSH into Soft Serve, add your own Soft Serve instance entry to your SSH\nconfig. For instance, to use `ssh soft` instead of typing `ssh localhost -i\n~/.ssh/id_ed25519 -o IdentitiesOnly=yes -p 23231`, we can define a `soft` entry in our SSH config\nfile `~/.ssh/config`.\n\n```conf\nHost soft\n  HostName localhost\n  Port 23231\n  IdentityFile ~/.ssh/id_ed25519\n  IdentitiesOnly yes\n```\n\nNow, we can do `ssh soft` to SSH into Soft Serve. Since `git` is also aware of\nthis config, you can use `soft` as the hostname for your clone commands.\n\n```sh\ngit clone ssh://soft/dotfiles\n# make changes\n# add & commit\ngit push origin main\n```\n\n> **Note** The `-i` and `-o` parts will be omitted in the examples below for brevity. You\n> can add your server settings to your sshconfig for quicker access.\n\n### Authentication\n\nEverything that needs authentication is done using SSH. Make sure you have\nadded an entry for your Soft Serve instance in your `~/.ssh/config` file.\n\nBy default, Soft Serve gives read-only permission to anonymous connections to\nany of the above protocols. This is controlled by two settings `anon-access`\nand `allow-keyless`.\n\n- `anon-access`: Defines the access level for anonymous users. Available\n  options are `no-access`, `read-only`, `read-write`, and `admin-access`.\n  Default is `read-only`.\n- `allow-keyless`: Whether to allow connections that doesn't use keys to pass.\n  Setting this to `false` would disable access to SSH keyboard-interactive,\n  HTTP, and Git protocol connections. Default is `true`.\n\n```sh\n$ ssh -p 23231 localhost settings\nManage server settings\n\nUsage:\n  ssh -p 23231 localhost settings [command]\n\nAvailable Commands:\n  allow-keyless Set or get allow keyless access to repositories\n  anon-access   Set or get the default access level for anonymous users\n\nFlags:\n  -h, --help   help for settings\n\nUse \"ssh -p 23231 localhost settings [command] --help\" for more information about a command.\n```\n\n> **Note** These settings can only be changed by admins.\n\nWhen `allow-keyless` is disabled, connections that don't use SSH Public Key\nauthentication will get denied. This means cloning repos over HTTP(s) or git://\nwill get denied.\n\nMeanwhile, `anon-access` controls the access level granted to connections that\nuse SSH Public Key authentication but are not registered users. The default\nsetting for this is `read-only`. This will grant anonymous connections that use\nSSH Public Key authentication `read-only` access to public repos.\n\n`anon-access` is also used in combination with `allow-keyless` to determine the\naccess level for HTTP(s) and git:// clone requests.\n\n#### SSH\n\nSoft Serve doesn't allow duplicate SSH public keys for users. A public key can be associated with one user only. This makes SSH authentication simple and straight forward, add your public key to your Soft Serve user to be able to access Soft Serve.\n\n#### HTTP\n\nYou can generate user access tokens through the SSH command line interface. Access tokens can have an optional expiration date. Use your access token as the basic auth user to access your Soft Serve repos through HTTP.\n\n```sh\n# Create a user token\nssh -p 23231 localhost token create 'my new token'\nss_1234abc56789012345678901234de246d798fghi\n\n# Or with an expiry date\nssh -p 23231 localhost token create --expires-in 1y 'my other token'\nss_98fghi1234abc56789012345678901234de246d7\n```\n\nNow you can access to repos that require `read-write` access.\n\n```sh\ngit clone http://ss_98fghi1234abc56789012345678901234de246d7@localhost:23232/my-private-repo.git my-private-repo\n# Make changes and push\n```\n\n### Authorization\n\nSoft Serve offers a simple access control. There are four access levels,\nno-access, read-only, read-write, and admin-access.\n\n`admin-access` has full control of the server and can make changes to users and repos.\n\n`read-write` access gets full control of repos.\n\n`read-only` can read public repos.\n\n`no-access` denies access to all repos.\n\n## User Management\n\nAdmins can manage users and their keys using the `user` command. Once a user is\ncreated and has access to the server, they can manage their own keys and\nsettings.\n\nTo create a new user simply use `user create`:\n\n```sh\n# Create a new user\nssh -p 23231 localhost user create beatrice\n\n# Add user keys\nssh -p 23231 localhost user add-pubkey beatrice ssh-rsa AAAAB3Nz...\nssh -p 23231 localhost user add-pubkey beatrice ssh-ed25519 AAAA...\n\n# Create another user with public key\nssh -p 23231 localhost user create frankie '-k \"ssh-ed25519 AAAATzN...\"'\n\n# Need help?\nssh -p 23231 localhost user help\n```\n\nOnce a user is created, they get `read-only` access to public repositories.\nThey can also create new repositories on the server.\n\nUsers can manage their keys using the `pubkey` command:\n\n```sh\n# List user keys\nssh -p 23231 localhost pubkey list\n\n# Add key\nssh -p 23231 localhost pubkey add ssh-ed25519 AAAA...\n\n# Wanna change your username?\nssh -p 23231 localhost set-username yolo\n\n# To display user info\nssh -p 23231 localhost info\n```\n\n## Repositories\n\nYou can manage repositories using the `repo` command.\n\n```sh\n# Run repo help\n$ ssh -p 23231 localhost repo help\nManage repositories\n\nUsage:\n  ssh -p 23231 localhost repo [command]\n\nAliases:\n  repo, repos, repository, repositories\n\nAvailable Commands:\n  blob         Print out the contents of file at path\n  branch       Manage repository branches\n  collab       Manage collaborators\n  create       Create a new repository\n  delete       Delete a repository\n  description  Set or get the description for a repository\n  hide         Hide or unhide a repository\n  import       Import a new repository from remote\n  info         Get information about a repository\n  is-mirror    Whether a repository is a mirror\n  list         List repositories\n  private      Set or get a repository private property\n  project-name Set or get the project name for a repository\n  rename       Rename an existing repository\n  tag          Manage repository tags\n  tree         Print repository tree at path\n\nFlags:\n  -h, --help   help for repo\n\nUse \"ssh -p 23231 localhost repo [command] --help\" for more information about a command.\n```\n\nTo use any of the above `repo` commands, a user must be a collaborator in the repository. More on this below.\n\n### Creating Repositories\n\nTo create a repository, first make sure you are a registered user. Use the\n`repo create <repo>` command to create a new repository:\n\n```sh\n# Create a new repository\nssh -p 23231 localhost repo create icecream\n\n# Create a repo with description\nssh -p 23231 localhost repo create icecream '-d \"This is an Ice Cream description\"'\n\n# ... and project name\nssh -p 23231 localhost repo create icecream '-d \"This is an Ice Cream description\"' '-n \"Ice Cream\"'\n\n# I need my repository private!\nssh -p 23231 localhost repo create icecream -p '-d \"This is an Ice Cream description\"' '-n \"Ice Cream\"'\n\n# Help?\nssh -p 23231 localhost repo create -h\n```\n\nOr you can add your Soft Serve server as a remote to any existing repo, given\nyou have write access, and push to remote:\n\n```\ngit remote add origin ssh://localhost:23231/icecream\n```\n\nAfter you’ve added the remote just go ahead and push. If the repo doesn’t exist\non the server it’ll be created.\n\n```\ngit push origin main\n```\n\n### Nested Repositories\n\nRepositories can be nested too:\n\n```sh\n# Create a new nested repository\nssh -p 23231 localhost repo create charmbracelet/icecream\n\n# Or ...\ngit remote add charm ssh://localhost:23231/charmbracelet/icecream\ngit push charm main\n```\n\n### Mirrors\n\nYou can also *import* repositories from any public remote. Use the `repo import` command.\n\n```sh\nssh -p 23231 localhost repo import soft-serve https://github.com/charmbracelet/soft-serve\n```\n\nUse `--mirror` or `-m` to mark the repository as a *pull* mirror.\n\n### Deleting Repositories\n\nYou can delete repositories using the `repo delete <repo>` command.\n\n```sh\nssh -p 23231 localhost repo delete icecream\n```\n\n### Renaming Repositories\n\nUse the `repo rename <old> <new>` command to rename existing repositories.\n\n```sh\nssh -p 23231 localhost repo rename icecream vanilla\n```\n\n### Repository Collaborators\n\nSometimes you want to restrict write access to certain repositories. This can\nbe achieved by adding a collaborator to your repository.\n\nUse the `repo collab <command> <repo>` command to manage repo collaborators.\n\n```sh\n# Add collaborator to soft-serve\nssh -p 23231 localhost repo collab add soft-serve frankie\n\n# Add collaborator with a specific access level\nssh -p 23231 localhost repo collab add soft-serve beatrice read-only\n\n# Remove collaborator\nssh -p 23231 localhost repo collab remove soft-serve beatrice\n\n# List collaborators\nssh -p 23231 localhost repo collab list soft-serve\n```\n\n### Repository Metadata\n\nYou can also change the repo's description, project name, whether it's private,\netc using the `repo <command>` command.\n\n```sh\n# Set description for repo\nssh -p 23231 localhost repo description icecream \"This is a new description\"\n\n# Hide repo from listing\nssh -p 23231 localhost repo hidden icecream true\n\n# List repository info (branches, tags, description, etc)\nssh -p 23231 localhost repo icecream info\n```\n\nTo make a repository private, use `repo private <repo> [true|false]`. Private\nrepos can only be accessed by admins and collaborators.\n\n```sh\nssh -p 23231 localhost repo private icecream true\n```\n\n### Repository Branches & Tags\n\nUse `repo branch` and `repo tag` to list, and delete branches or tags. You can\nalso use `repo branch default` to set or get the repository default branch.\n\n### Repository Tree\n\nTo print a file tree for the project, just use the `repo tree` command along with\nthe repo name as the SSH command to your Soft Serve server:\n\n```sh\nssh -p 23231 localhost repo tree soft-serve\n```\n\nYou can also specify the sub-path and a specific reference or branch.\n\n```sh\nssh -p 23231 localhost repo tree soft-serve server/config\nssh -p 23231 localhost repo tree soft-serve main server/config\n```\n\nFrom there, you can print individual files using the `repo blob` command:\n\n```sh\nssh -p 23231 localhost repo blob soft-serve cmd/soft/main.go\n```\n\nYou can add the `-c` flag to enable syntax coloring and `-l` to print line\nnumbers:\n\n```sh\nssh -p 23231 localhost repo blob soft-serve cmd/soft/main.go -c -l\n\n```\n\nUse `--raw` to print raw file contents. This is useful for dumping binary data.\n\n### Repository webhooks\n\nSoft Serve supports repository webhooks using the `repo webhook` command. You\ncan create and manage webhooks for different repository events such as _push_,\n_collaborators_, and _branch_tag_create_ events.\n\n```\nManage repository webhooks\n\nUsage:\n  ssh -p 23231 localhost repo webhook [command]\n\nAliases:\n  webhook, webhooks\n\nAvailable Commands:\n  create      Create a repository webhook\n  delete      Delete a repository webhook\n  deliveries  Manage webhook deliveries\n  list        List repository webhooks\n  update      Update a repository webhook\n\nFlags:\n  -h, --help   help for webhook\n```\n\n## The Soft Serve TUI\n\n<img src=\"https://stuff.charm.sh/soft-serve/soft-serve-demo-commit.png\" width=\"750\" alt=\"TUI example showing a diff\">\n\nSoft Serve TUI is mainly used to browse repos over SSH. You can also use it to\nbrowse local repositories with `soft browse` or running `soft` within a Git\nrepository.\n\n```sh\nssh localhost -p 23231\n```\n\nIt's also possible to “link” to a specific repo:\n\n```sh\nssh -p 23231 localhost -t soft-serve\n```\n\nYou can copy text to your clipboard over SSH. For instance, you can press\n<kbd>c</kbd> on the highlighted repo in the menu to copy the clone command\n[^osc52].\n\n[^osc52]:\n    Copying over SSH depends on your terminal support of OSC52. Refer to\n    [go-osc52](https://github.com/aymanbagabas/go-osc52) for more information.\n\n## Hooks\n\nSoft Serve supports git server-side hooks `pre-receive`, `update`,\n`post-update`, and `post-receive`. This means you can define your own hooks to\nrun on repository push events. Hooks can be defined as a per-repository hook,\nand/or global hooks that run for all repositories.\n\nYou can find per-repository hooks under the repository `hooks` directory.\n\nGlobs hooks can be found in your `SOFT_SERVE_DATA_PATH` directory under\n`hooks`. Defining global hooks is useful if you want to run CI/CD for example.\n\nHere's an example of sending a message after receiving a push event. Create an\nexecutable file `<data path>/hooks/update`:\n\n```sh\n#!/bin/sh\n#\n# An example hook script to echo information about the push\n# and send it to the client.\n\nrefname=\"$1\"\noldrev=\"$2\"\nnewrev=\"$3\"\n\n# Safety check\nif [ -z \"$GIT_DIR\" ]; then\n        echo \"Don't run this script from the command line.\" >&2\n        echo \" (if you want, you could supply GIT_DIR then run\" >&2\n        echo \"  $0 <ref> <oldrev> <newrev>)\" >&2\n        exit 1\nfi\n\nif [ -z \"$refname\" -o -z \"$oldrev\" -o -z \"$newrev\" ]; then\n        echo \"usage: $0 <ref> <oldrev> <newrev>\" >&2\n        exit 1\nfi\n\n# Check types\n# if $newrev is 0000...0000, it's a commit to delete a ref.\nzero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')\nif [ \"$newrev\" = \"$zero\" ]; then\n        newrev_type=delete\nelse\n        newrev_type=$(git cat-file -t $newrev)\nfi\n\necho \"Hi from Soft Serve update hook!\"\necho\necho \"RefName: $refname\"\necho \"Change Type: $newrev_type\"\necho \"Old SHA1: $oldrev\"\necho \"New SHA1: $newrev\"\n\nexit 0\n```\n\nNow, you should get a message after pushing changes to any repository.\n\n## A note about RSA keys\n\nUnfortunately, due to a shortcoming in Go’s `x/crypto/ssh` package, Soft Serve\ndoes not currently support access via new SSH RSA keys: only the old SHA-1\nones will work.\n\nUntil we sort this out you’ll either need an SHA-1 RSA key or a key with\nanother algorithm, e.g. Ed25519. Not sure what type of keys you have?\nYou can check with the following:\n\n```sh\n$ find ~/.ssh/id_*.pub -exec ssh-keygen -l -f {} \\;\n```\n\nIf you’re curious about the inner workings of this problem have a look at:\n\n- https://github.com/golang/go/issues/37278\n- https://go-review.googlesource.com/c/crypto/+/220037\n- https://github.com/golang/crypto/pull/197\n\n## Contributing\n\nSee [contributing][contribute].\n\n[contribute]: https://github.com/charmbracelet/soft-serve/contribute\n\n## Feedback\n\nWe’d love to hear your thoughts on this project. Feel free to drop us a note!\n\n- [Twitter](https://twitter.com/charmcli)\n- [The Fediverse](https://mastodon.social/@charmcli)\n- [Discord](https://charm.sh/chat)\n\n## License\n\n[MIT](https://github.com/charmbracelet/soft-serve/raw/main/LICENSE)\n\n---\n\nPart of [Charm](https://charm.sh).\n\n<a href=\"https://charm.sh/\"><img alt=\"The Charm logo\" src=\"https://stuff.charm.sh/charm-badge.jpg\" width=\"400\"></a>\n\nCharm热爱开源 • Charm loves open source\n"
  },
  {
    "path": "browse.tape",
    "content": "Set Width 1600\nSet Height 900\nSet FontSize 22\n\nOutput soft-serve-browse.gif\nOutput soft-serve-frames/\n\nType@300ms \"soft\"\nEnter\nSleep 2s\nType@1s \"ddd\"\nSleep 2s\nType@1s \"uuu\"\nSleep 2s\nTab@1s\nSleep 1s\nDown@300ms 4\nEnter\nSleep 1s\nDown@300ms 13\nEnter\nSleep 1s\nDown@300ms 5\nEnter\nDown@300ms 20\nSleep 2s\nType@500ms \"b\"\nSleep 2.5s\nDown@300ms 50\nSleep 2.5s\nTab@1s\nDown@500ms 4\nUp@500ms 2\nEnter\nDown@250ms 50\nSleep 1s\nTab@1s\nDown@500ms 8\nEnter\nDown@250ms 30\nTab@2s\nDown@500ms 5\nUp@500ms 2\nSleep 2.5s\nTab@2s\nDown@500ms 8\nSleep 2s\n"
  },
  {
    "path": "cmd/cmd.go",
    "content": "package cmd\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"os\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/hooks\"\n\t\"github.com/charmbracelet/soft-serve/pkg/store\"\n\t\"github.com/charmbracelet/soft-serve/pkg/store/database\"\n\t\"github.com/spf13/cobra\"\n)\n\n// InitBackendContext initializes the backend context.\nfunc InitBackendContext(cmd *cobra.Command, _ []string) error {\n\tctx := cmd.Context()\n\tcfg := config.FromContext(ctx)\n\tif _, err := os.Stat(cfg.DataPath); errors.Is(err, fs.ErrNotExist) {\n\t\tif err := os.MkdirAll(cfg.DataPath, os.ModePerm); err != nil {\n\t\t\treturn fmt.Errorf(\"create data directory: %w\", err)\n\t\t}\n\t}\n\tdbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"open database: %w\", err)\n\t}\n\n\tctx = db.WithContext(ctx, dbx)\n\tdbstore := database.New(ctx, dbx)\n\tctx = store.WithContext(ctx, dbstore)\n\tbe := backend.New(ctx, cfg, dbx, dbstore)\n\tctx = backend.WithContext(ctx, be)\n\n\tcmd.SetContext(ctx)\n\n\treturn nil\n}\n\n// CloseDBContext closes the database context.\nfunc CloseDBContext(cmd *cobra.Command, _ []string) error {\n\tctx := cmd.Context()\n\tdbx := db.FromContext(ctx)\n\tif dbx != nil {\n\t\tif err := dbx.Close(); err != nil {\n\t\t\treturn fmt.Errorf(\"close database: %w\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// InitializeHooks initializes the hooks.\nfunc InitializeHooks(ctx context.Context, cfg *config.Config, be *backend.Backend) error {\n\trepos, err := be.Repositories(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, repo := range repos {\n\t\tif err := hooks.GenerateHooks(ctx, cfg, repo.Name()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/soft/admin/admin.go",
    "content": "package admin\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/charmbracelet/soft-serve/cmd\"\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db/migrate\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar (\n\t// Command is the admin command.\n\tCommand = &cobra.Command{\n\t\tUse:   \"admin\",\n\t\tShort: \"Administrate the server\",\n\t}\n\n\tmigrateCmd = &cobra.Command{\n\t\tUse:                \"migrate\",\n\t\tShort:              \"Migrate the database to the latest version\",\n\t\tPersistentPreRunE:  cmd.InitBackendContext,\n\t\tPersistentPostRunE: cmd.CloseDBContext,\n\t\tRunE: func(cmd *cobra.Command, _ []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tdb := db.FromContext(ctx)\n\t\t\tif err := migrate.Migrate(ctx, db); err != nil {\n\t\t\t\treturn fmt.Errorf(\"migration: %w\", err)\n\t\t\t}\n\n\t\t\treturn nil\n\t\t},\n\t}\n\n\trollbackCmd = &cobra.Command{\n\t\tUse:                \"rollback\",\n\t\tShort:              \"Rollback the database to the previous version\",\n\t\tPersistentPreRunE:  cmd.InitBackendContext,\n\t\tPersistentPostRunE: cmd.CloseDBContext,\n\t\tRunE: func(cmd *cobra.Command, _ []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tdb := db.FromContext(ctx)\n\t\t\tif err := migrate.Rollback(ctx, db); err != nil {\n\t\t\t\treturn fmt.Errorf(\"rollback: %w\", err)\n\t\t\t}\n\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tsyncHooksCmd = &cobra.Command{\n\t\tUse:                \"sync-hooks\",\n\t\tShort:              \"Update repository hooks\",\n\t\tPersistentPreRunE:  cmd.InitBackendContext,\n\t\tPersistentPostRunE: cmd.CloseDBContext,\n\t\tRunE: func(c *cobra.Command, _ []string) error {\n\t\t\tctx := c.Context()\n\t\t\tcfg := config.FromContext(ctx)\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\tif err := cmd.InitializeHooks(ctx, cfg, be); err != nil {\n\t\t\t\treturn fmt.Errorf(\"initialize hooks: %w\", err)\n\t\t\t}\n\n\t\t\treturn nil\n\t\t},\n\t}\n)\n\nfunc init() {\n\tCommand.AddCommand(\n\t\tsyncHooksCmd,\n\t\tmigrateCmd,\n\t\trollbackCmd,\n\t)\n}\n"
  },
  {
    "path": "cmd/soft/browse/browse.go",
    "content": "package browse\n\nimport (\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"time\"\n\n\t\"charm.land/bubbles/v2/key\"\n\ttea \"charm.land/bubbletea/v2\"\n\t\"charm.land/lipgloss/v2\"\n\t\"github.com/charmbracelet/soft-serve/git\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/common\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/components/footer\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/pages/repo\"\n\t\"github.com/spf13/cobra\"\n)\n\n// Command is the browse command.\nvar Command = &cobra.Command{\n\tUse:   \"browse PATH\",\n\tShort: \"Browse a repository\",\n\tArgs:  cobra.MaximumNArgs(1),\n\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\trp := \".\"\n\t\tif len(args) > 0 {\n\t\t\trp = args[0]\n\t\t}\n\n\t\tabs, err := filepath.Abs(rp)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tr, err := git.Open(abs)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to open repository: %w\", err)\n\t\t}\n\n\t\t// Bubble Tea uses Termenv default output so we have to use the same\n\t\t// thing here.\n\t\tctx := cmd.Context()\n\t\tc := common.NewCommon(ctx, 0, 0)\n\t\tc.HideCloneCmd = true\n\t\tcomps := []common.TabComponent{\n\t\t\trepo.NewReadme(c),\n\t\t\trepo.NewFiles(c),\n\t\t\trepo.NewLog(c),\n\t\t}\n\t\tif !r.IsBare {\n\t\t\tcomps = append(comps, repo.NewStash(c))\n\t\t}\n\t\tcomps = append(comps, repo.NewRefs(c, git.RefsHeads), repo.NewRefs(c, git.RefsTags))\n\t\tm := &model{\n\t\t\tmodel:  repo.New(c, comps...),\n\t\t\trepo:   repository{r},\n\t\t\tcommon: c,\n\t\t}\n\n\t\tm.footer = footer.New(c, m)\n\t\tp := tea.NewProgram(m)\n\n\t\t_, err = p.Run()\n\t\treturn err\n\t},\n}\n\ntype state int\n\nconst (\n\tstartState state = iota\n\terrorState\n)\n\ntype model struct {\n\tmodel      *repo.Repo\n\tfooter     *footer.Footer\n\trepo       proto.Repository\n\tcommon     common.Common\n\tstate      state\n\tshowFooter bool\n\terror      error\n}\n\nvar _ tea.Model = &model{}\n\nfunc (m *model) SetSize(w, h int) {\n\tm.common.SetSize(w, h)\n\tstyle := m.common.Styles.App\n\twm := style.GetHorizontalFrameSize()\n\thm := style.GetVerticalFrameSize()\n\tif m.showFooter {\n\t\thm += m.footer.Height()\n\t}\n\n\tm.footer.SetSize(w-wm, h-hm)\n\tm.model.SetSize(w-wm, h-hm)\n}\n\n// ShortHelp implements help.KeyMap.\nfunc (m model) ShortHelp() []key.Binding {\n\tswitch m.state {\n\tcase errorState:\n\t\treturn []key.Binding{\n\t\t\tm.common.KeyMap.Back,\n\t\t\tm.common.KeyMap.Quit,\n\t\t\tm.common.KeyMap.Help,\n\t\t}\n\tdefault:\n\t\treturn m.model.ShortHelp()\n\t}\n}\n\n// FullHelp implements help.KeyMap.\nfunc (m model) FullHelp() [][]key.Binding {\n\tswitch m.state {\n\tcase errorState:\n\t\treturn [][]key.Binding{\n\t\t\t{\n\t\t\t\tm.common.KeyMap.Back,\n\t\t\t},\n\t\t\t{\n\t\t\t\tm.common.KeyMap.Quit,\n\t\t\t\tm.common.KeyMap.Help,\n\t\t\t},\n\t\t}\n\tdefault:\n\t\treturn m.model.FullHelp()\n\t}\n}\n\n// Init implements tea.Model.\nfunc (m *model) Init() tea.Cmd {\n\treturn tea.Batch(\n\t\tm.model.Init(),\n\t\tm.footer.Init(),\n\t\tfunc() tea.Msg {\n\t\t\treturn repo.RepoMsg(m.repo)\n\t\t},\n\t\trepo.UpdateRefCmd(m.repo),\n\t)\n}\n\n// Update implements tea.Model.\nfunc (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {\n\tm.common.Logger.Debugf(\"msg received: %T\", msg)\n\tcmds := make([]tea.Cmd, 0)\n\tswitch msg := msg.(type) {\n\tcase tea.WindowSizeMsg:\n\t\tm.SetSize(msg.Width, msg.Height)\n\tcase tea.KeyPressMsg:\n\t\tswitch {\n\t\tcase key.Matches(msg, m.common.KeyMap.Back) && m.error != nil:\n\t\t\tm.error = nil\n\t\t\tm.state = startState\n\t\t\t// Always show the footer on error.\n\t\t\tm.showFooter = m.footer.ShowAll()\n\t\tcase key.Matches(msg, m.common.KeyMap.Help):\n\t\t\tcmds = append(cmds, footer.ToggleFooterCmd)\n\t\tcase key.Matches(msg, m.common.KeyMap.Quit):\n\t\t\t// Stop bubblezone background workers.\n\t\t\tm.common.Zone.Close()\n\t\t\treturn m, tea.Quit\n\t\t}\n\tcase tea.MouseClickMsg:\n\t\tmouse := msg.Mouse()\n\t\tswitch mouse.Button {\n\t\tcase tea.MouseLeft:\n\t\t\tswitch {\n\t\t\tcase m.common.Zone.Get(\"footer\").InBounds(msg):\n\t\t\t\tcmds = append(cmds, footer.ToggleFooterCmd)\n\t\t\t}\n\t\t}\n\tcase footer.ToggleFooterMsg:\n\t\tm.footer.SetShowAll(!m.footer.ShowAll())\n\t\tm.showFooter = !m.showFooter\n\tcase common.ErrorMsg:\n\t\tm.error = msg\n\t\tm.state = errorState\n\t\tm.showFooter = true\n\t}\n\n\tf, cmd := m.footer.Update(msg)\n\tm.footer = f.(*footer.Footer)\n\tif cmd != nil {\n\t\tcmds = append(cmds, cmd)\n\t}\n\n\tr, cmd := m.model.Update(msg)\n\tm.model = r.(*repo.Repo)\n\tif cmd != nil {\n\t\tcmds = append(cmds, cmd)\n\t}\n\n\t// This fixes determining the height margin of the footer.\n\tm.SetSize(m.common.Width, m.common.Height)\n\n\treturn m, tea.Batch(cmds...)\n}\n\n// View implements tea.Model.\nfunc (m *model) View() tea.View {\n\tvar v tea.View\n\tv.AltScreen = true\n\tv.MouseMode = tea.MouseModeCellMotion\n\n\tstyle := m.common.Styles.App\n\twm, hm := style.GetHorizontalFrameSize(), style.GetVerticalFrameSize()\n\tif m.showFooter {\n\t\thm += m.footer.Height()\n\t}\n\n\tvar view string\n\tswitch m.state {\n\tcase startState:\n\t\tview = m.model.View()\n\tcase errorState:\n\t\terr := m.common.Styles.ErrorTitle.Render(\"Bummer\")\n\t\terr += m.common.Styles.ErrorBody.Render(m.error.Error())\n\t\tview = m.common.Styles.Error.\n\t\t\tWidth(m.common.Width -\n\t\t\t\twm -\n\t\t\t\tm.common.Styles.ErrorBody.GetHorizontalFrameSize()).\n\t\t\tHeight(m.common.Height -\n\t\t\t\thm -\n\t\t\t\tm.common.Styles.Error.GetVerticalFrameSize()).\n\t\t\tRender(err)\n\t}\n\n\tif m.showFooter {\n\t\tview = lipgloss.JoinVertical(lipgloss.Left, view, m.footer.View())\n\t}\n\n\tv.Content = m.common.Zone.Scan(style.Render(view))\n\treturn v\n}\n\ntype repository struct {\n\tr *git.Repository\n}\n\nvar _ proto.Repository = repository{}\n\n// Description implements proto.Repository.\nfunc (r repository) Description() string {\n\treturn \"\"\n}\n\n// ID implements proto.Repository.\nfunc (r repository) ID() int64 {\n\treturn 0\n}\n\n// IsHidden implements proto.Repository.\nfunc (repository) IsHidden() bool {\n\treturn false\n}\n\n// IsMirror implements proto.Repository.\nfunc (repository) IsMirror() bool {\n\treturn false\n}\n\n// IsPrivate implements proto.Repository.\nfunc (repository) IsPrivate() bool {\n\treturn false\n}\n\n// Name implements proto.Repository.\nfunc (r repository) Name() string {\n\treturn filepath.Base(r.r.Path)\n}\n\n// Open implements proto.Repository.\nfunc (r repository) Open() (*git.Repository, error) {\n\treturn r.r, nil\n}\n\n// ProjectName implements proto.Repository.\nfunc (r repository) ProjectName() string {\n\treturn r.Name()\n}\n\n// UpdatedAt implements proto.Repository.\nfunc (r repository) UpdatedAt() time.Time {\n\tt, err := r.r.LatestCommitTime()\n\tif err != nil {\n\t\treturn time.Time{}\n\t}\n\n\treturn t\n}\n\n// UserID implements proto.Repository.\nfunc (r repository) UserID() int64 {\n\treturn 0\n}\n\n// CreatedAt implements proto.Repository.\nfunc (r repository) CreatedAt() time.Time {\n\treturn time.Time{}\n}\n"
  },
  {
    "path": "cmd/soft/hook/hook.go",
    "content": "package hook\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"charm.land/log/v2\"\n\t\"github.com/charmbracelet/soft-serve/cmd\"\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/hooks\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar (\n\t// ErrInternalServerError indicates that an internal server error occurred.\n\tErrInternalServerError = errors.New(\"internal server error\")\n\n\t// Deprecated: this flag is ignored.\n\tconfigPath string\n\n\t// Command is the hook command.\n\tCommand = &cobra.Command{\n\t\tUse:    \"hook\",\n\t\tShort:  \"Run git server hooks\",\n\t\tLong:   \"Handles Soft Serve git server hooks.\",\n\t\tHidden: true,\n\t\tPersistentPreRunE: func(c *cobra.Command, args []string) error {\n\t\t\tlogger := log.FromContext(c.Context())\n\t\t\tif err := cmd.InitBackendContext(c, args); err != nil {\n\t\t\t\tlogger.Error(\"failed to initialize backend context\", \"err\", err)\n\t\t\t\treturn ErrInternalServerError\n\t\t\t}\n\n\t\t\treturn nil\n\t\t},\n\t\tPersistentPostRunE: func(c *cobra.Command, args []string) error {\n\t\t\tlogger := log.FromContext(c.Context())\n\t\t\tif err := cmd.CloseDBContext(c, args); err != nil {\n\t\t\t\tlogger.Error(\"failed to close backend\", \"err\", err)\n\t\t\t\treturn ErrInternalServerError\n\t\t\t}\n\n\t\t\treturn nil\n\t\t},\n\t}\n\n\t// Git hooks read the config from the environment, based on\n\t// $SOFT_SERVE_DATA_PATH. We already parse the config when the binary\n\t// starts, so we don't need to do it again.\n\t// The --config flag is now deprecated.\n\thooksRunE = func(cmd *cobra.Command, args []string) error {\n\t\tctx := cmd.Context()\n\t\thks := backend.FromContext(ctx)\n\t\tcfg := config.FromContext(ctx)\n\n\t\t// This is set in the server before invoking git-receive-pack/git-upload-pack\n\t\trepoName := os.Getenv(\"SOFT_SERVE_REPO_NAME\")\n\n\t\tlogger := log.FromContext(ctx).With(\"repo\", repoName)\n\n\t\tstdin := cmd.InOrStdin()\n\t\tstdout := cmd.OutOrStdout()\n\t\tstderr := cmd.ErrOrStderr()\n\n\t\tcmdName := cmd.Name()\n\t\tcustomHookPath := filepath.Join(cfg.DataPath, \"hooks\", cmdName)\n\n\t\tvar buf bytes.Buffer\n\t\topts := make([]hooks.HookArg, 0)\n\n\t\tswitch cmdName {\n\t\tcase hooks.PreReceiveHook, hooks.PostReceiveHook:\n\t\t\tscanner := bufio.NewScanner(stdin)\n\t\t\tfor scanner.Scan() {\n\t\t\t\tbuf.Write(scanner.Bytes())\n\t\t\t\tbuf.WriteByte('\\n')\n\t\t\t\tfields := strings.Fields(scanner.Text())\n\t\t\t\tif len(fields) != 3 {\n\t\t\t\t\tlogger.Error(fmt.Sprintf(\"invalid %s hook input\", cmdName), \"input\", scanner.Text())\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\topts = append(opts, hooks.HookArg{\n\t\t\t\t\tOldSha:  fields[0],\n\t\t\t\t\tNewSha:  fields[1],\n\t\t\t\t\tRefName: fields[2],\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tswitch cmdName {\n\t\t\tcase hooks.PreReceiveHook:\n\t\t\t\thks.PreReceive(ctx, stdout, stderr, repoName, opts)\n\t\t\tcase hooks.PostReceiveHook:\n\t\t\t\thks.PostReceive(ctx, stdout, stderr, repoName, opts)\n\t\t\t}\n\t\tcase hooks.UpdateHook:\n\t\t\tif len(args) != 3 {\n\t\t\t\tlogger.Error(\"invalid update hook input\", \"input\", args)\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\thks.Update(ctx, stdout, stderr, repoName, hooks.HookArg{\n\t\t\t\tRefName: args[0],\n\t\t\t\tOldSha:  args[1],\n\t\t\t\tNewSha:  args[2],\n\t\t\t})\n\t\tcase hooks.PostUpdateHook:\n\t\t\thks.PostUpdate(ctx, stdout, stderr, repoName, args...)\n\t\t}\n\n\t\t// Custom hooks\n\t\tif stat, err := os.Stat(customHookPath); err == nil && !stat.IsDir() && stat.Mode()&0o111 != 0 {\n\t\t\t// If the custom hook is executable, run it\n\t\t\tif err := runCommand(ctx, &buf, stdout, stderr, customHookPath, args...); err != nil {\n\t\t\t\tlogger.Error(\"failed to run custom hook\", \"err\", err)\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}\n\n\tpreReceiveCmd = &cobra.Command{\n\t\tUse:   \"pre-receive\",\n\t\tShort: \"Run git pre-receive hook\",\n\t\tRunE:  hooksRunE,\n\t}\n\n\tupdateCmd = &cobra.Command{\n\t\tUse:   \"update\",\n\t\tShort: \"Run git update hook\",\n\t\tArgs:  cobra.ExactArgs(3),\n\t\tRunE:  hooksRunE,\n\t}\n\n\tpostReceiveCmd = &cobra.Command{\n\t\tUse:   \"post-receive\",\n\t\tShort: \"Run git post-receive hook\",\n\t\tRunE:  hooksRunE,\n\t}\n\n\tpostUpdateCmd = &cobra.Command{\n\t\tUse:   \"post-update\",\n\t\tShort: \"Run git post-update hook\",\n\t\tRunE:  hooksRunE,\n\t}\n)\n\nfunc init() {\n\tCommand.PersistentFlags().StringVar(&configPath, \"config\", \"\", \"path to config file (deprecated)\")\n\tCommand.AddCommand(\n\t\tpreReceiveCmd,\n\t\tupdateCmd,\n\t\tpostReceiveCmd,\n\t\tpostUpdateCmd,\n\t)\n}\n\nfunc runCommand(ctx context.Context, in io.Reader, out io.Writer, err io.Writer, name string, args ...string) error {\n\tcmd := exec.CommandContext(ctx, name, args...)\n\tcmd.Stdin = in\n\tcmd.Stdout = out\n\tcmd.Stderr = err\n\treturn cmd.Run()\n}\n"
  },
  {
    "path": "cmd/soft/main.go",
    "content": "package main\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"runtime/debug\"\n\t\"strconv\"\n\n\t\"charm.land/log/v2\"\n\t\"github.com/charmbracelet/colorprofile\"\n\t\"github.com/charmbracelet/soft-serve/cmd/soft/admin\"\n\t\"github.com/charmbracelet/soft-serve/cmd/soft/browse\"\n\t\"github.com/charmbracelet/soft-serve/cmd/soft/hook\"\n\t\"github.com/charmbracelet/soft-serve/cmd/soft/serve\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\tlogr \"github.com/charmbracelet/soft-serve/pkg/log\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/common\"\n\t\"github.com/charmbracelet/soft-serve/pkg/version\"\n\tmcobra \"github.com/muesli/mango-cobra\"\n\t\"github.com/muesli/roff\"\n\t\"github.com/spf13/cobra\"\n\t\"go.uber.org/automaxprocs/maxprocs\"\n)\n\nvar (\n\t// Version contains the application version number. It's set via ldflags\n\t// when building.\n\tVersion = \"\"\n\n\t// CommitSHA contains the SHA of the commit that this application was built\n\t// against. It's set via ldflags when building.\n\tCommitSHA = \"\"\n\n\t// CommitDate contains the date of the commit that this application was\n\t// built against. It's set via ldflags when building.\n\tCommitDate = \"\"\n\n\trootCmd = &cobra.Command{\n\t\tUse:          \"soft\",\n\t\tShort:        \"A self-hostable Git server for the command line\",\n\t\tLong:         \"Soft Serve is a self-hostable Git server for the command line.\",\n\t\tSilenceUsage: true,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\treturn browse.Command.RunE(cmd, args)\n\t\t},\n\t}\n\n\tmanCmd = &cobra.Command{\n\t\tUse:    \"man\",\n\t\tShort:  \"Generate man pages\",\n\t\tArgs:   cobra.NoArgs,\n\t\tHidden: true,\n\t\tRunE: func(_ *cobra.Command, _ []string) error {\n\t\t\tmanPage, err := mcobra.NewManPage(1, rootCmd) //.\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tmanPage = manPage.WithSection(\"Copyright\", \"(C) 2021-2023 Charmbracelet, Inc.\\n\"+\n\t\t\t\t\"Released under MIT license.\")\n\t\t\tfmt.Println(manPage.Build(roff.NewDocument()))\n\t\t\treturn nil\n\t\t},\n\t}\n)\n\nfunc init() {\n\tif noColor, _ := strconv.ParseBool(os.Getenv(\"SOFT_SERVE_NO_COLOR\")); noColor {\n\t\tcommon.DefaultColorProfile = colorprofile.NoTTY\n\t}\n\n\trootCmd.AddCommand(\n\t\tmanCmd,\n\t\tserve.Command,\n\t\thook.Command,\n\t\tadmin.Command,\n\t\tbrowse.Command,\n\t)\n\trootCmd.CompletionOptions.HiddenDefaultCmd = true\n\n\tif len(CommitSHA) >= 7 {\n\t\tvt := rootCmd.VersionTemplate()\n\t\trootCmd.SetVersionTemplate(vt[:len(vt)-1] + \" (\" + CommitSHA[0:7] + \")\\n\")\n\t}\n\tif Version == \"\" {\n\t\tif info, ok := debug.ReadBuildInfo(); ok && info.Main.Sum != \"\" {\n\t\t\tVersion = info.Main.Version\n\t\t} else {\n\t\t\tVersion = \"unknown (built from source)\"\n\t\t}\n\t}\n\trootCmd.Version = Version\n\n\tversion.Version = Version\n\tversion.CommitSHA = CommitSHA\n\tversion.CommitDate = CommitDate\n}\n\nfunc main() {\n\tctx := context.Background()\n\tcfg := config.DefaultConfig()\n\tif cfg.Exist() {\n\t\tif err := cfg.Parse(); err != nil {\n\t\t\tlog.Fatal(err)\n\t\t}\n\t}\n\n\tif err := cfg.ParseEnv(); err != nil {\n\t\tlog.Fatal(err)\n\t}\n\n\tctx = config.WithContext(ctx, cfg)\n\tlogger, f, err := logr.NewLogger(cfg)\n\tif err != nil {\n\t\tlog.Errorf(\"failed to create logger: %v\", err)\n\t}\n\n\tctx = log.WithContext(ctx, logger)\n\tif f != nil {\n\t\tdefer f.Close() //nolint: errcheck\n\t}\n\n\t// Set global logger\n\tlog.SetDefault(logger)\n\n\tvar opts []maxprocs.Option\n\tif config.IsVerbose() {\n\t\topts = append(opts, maxprocs.Logger(log.Debugf))\n\t}\n\n\t// Set the max number of processes to the number of CPUs\n\t// This is useful when running soft serve in a container\n\tif _, err := maxprocs.Set(opts...); err != nil {\n\t\tlog.Warn(\"couldn't set automaxprocs\", \"error\", err)\n\t}\n\n\tif err := rootCmd.ExecuteContext(ctx); err != nil {\n\t\tos.Exit(1)\n\t}\n}\n"
  },
  {
    "path": "cmd/soft/serve/certreloader.go",
    "content": "package serve\n\nimport (\n\t\"crypto/tls\"\n\t\"sync\"\n\n\t\"charm.land/log/v2\"\n)\n\n// CertReloader is responsible for reloading TLS certificates when a SIGHUP signal is received.\ntype CertReloader struct {\n\tcertMu   sync.RWMutex\n\tcert     *tls.Certificate\n\tcertPath string\n\tkeyPath  string\n}\n\n// NewCertReloader creates a new CertReloader that watches for SIGHUP signals.\nfunc NewCertReloader(certPath, keyPath string, logger *log.Logger) (*CertReloader, error) {\n\treloader := &CertReloader{\n\t\tcertPath: certPath,\n\t\tkeyPath:  keyPath,\n\t}\n\n\tcert, err := tls.LoadX509KeyPair(certPath, keyPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treloader.cert = &cert\n\n\treturn reloader, nil\n}\n\n// Reload attempts to reload the certificate and key.\nfunc (cr *CertReloader) Reload() error {\n\tnewCert, err := tls.LoadX509KeyPair(cr.certPath, cr.keyPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tcr.certMu.Lock()\n\tdefer cr.certMu.Unlock()\n\tcr.cert = &newCert\n\treturn nil\n}\n\n// GetCertificateFunc returns a function that can be used with tls.Config.GetCertificate.\nfunc (cr *CertReloader) GetCertificateFunc() func(*tls.ClientHelloInfo) (*tls.Certificate, error) {\n\treturn func(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) {\n\t\tcr.certMu.RLock()\n\t\tdefer cr.certMu.RUnlock()\n\t\treturn cr.cert, nil\n\t}\n}\n"
  },
  {
    "path": "cmd/soft/serve/certreloader_test.go",
    "content": "//go:build unix\n\npackage serve\n\nimport (\n\t\"crypto/rand\"\n\t\"crypto/rsa\"\n\t\"crypto/x509\"\n\t\"crypto/x509/pkix\"\n\t\"encoding/pem\"\n\t\"os\"\n\t\"os/signal\"\n\t\"path/filepath\"\n\t\"syscall\"\n\t\"testing\"\n\t\"time\"\n\n\t\"charm.land/log/v2\"\n)\n\nfunc generateTestCert(t *testing.T, certPath, keyPath, cn string) {\n\tt.Helper()\n\n\tprivateKey, err := rsa.GenerateKey(rand.Reader, 2048)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\ttemplate := x509.Certificate{\n\t\tSerialNumber: nil,\n\t\tSubject: pkix.Name{\n\t\t\tCommonName: cn,\n\t\t},\n\t\tNotBefore: time.Now(),\n\t\tNotAfter:  time.Now().Add(time.Hour),\n\t}\n\n\tcertBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcertFile, err := os.Create(certPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer certFile.Close()\n\n\tpem.Encode(certFile, &pem.Block{Type: \"CERTIFICATE\", Bytes: certBytes})\n\n\tkeyFile, err := os.Create(keyPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer keyFile.Close()\n\n\tpem.Encode(keyFile, &pem.Block{\n\t\tType:  \"RSA PRIVATE KEY\",\n\t\tBytes: x509.MarshalPKCS1PrivateKey(privateKey),\n\t})\n}\n\nfunc TestCertReloader(t *testing.T) {\n\tdir := t.TempDir()\n\tcertPath := filepath.Join(dir, \"/cert.pem\")\n\tkeyPath := filepath.Join(dir, \"/key.pem\")\n\n\t// Initial cert\n\tgenerateTestCert(t, certPath, keyPath, \"cert-v1\")\n\n\tlogger := log.New(os.Stderr)\n\n\tcertReloader, err := NewCertReloader(certPath, keyPath, logger)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to create reloader: %v\", err)\n\t}\n\n\tgo func() {\n\t\tsigCh := make(chan os.Signal, 1)\n\t\tsignal.Notify(sigCh, syscall.SIGHUP)\n\t\tfor range sigCh {\n\t\t\tif err := certReloader.Reload(); err != nil {\n\t\t\t\tlogger.Error(\"failed to reload certificate\", \"err\", err)\n\t\t\t} else {\n\t\t\t\tlogger.Info(\"certificate reloaded successfully\")\n\t\t\t}\n\t\t}\n\t}()\n\n\tgetCert := certReloader.GetCertificateFunc()\n\n\tcert1, err := getCert(nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Replace cert on disk\n\tgenerateTestCert(t, certPath, keyPath, \"cert-v2\")\n\n\t// Trigger reload\n\tif err := syscall.Kill(os.Getpid(), syscall.SIGHUP); err != nil {\n\t\tt.Fatalf(\"failed to send SIGHUP: %v\", err)\n\t}\n\n\t// Allow async goroutine to reload\n\ttime.Sleep(100 * time.Millisecond)\n\n\tcert2, err := getCert(nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif cert1 == cert2 {\n\t\tt.Fatal(\"certificate was not reloaded after SIGHUP\")\n\t}\n}\n"
  },
  {
    "path": "cmd/soft/serve/serve.go",
    "content": "package serve\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/signal\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"sync\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/charmbracelet/soft-serve/cmd\"\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db/migrate\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar (\n\tsyncHooks bool\n\n\t// Command is the serve command.\n\tCommand = &cobra.Command{\n\t\tUse:                \"serve\",\n\t\tShort:              \"Start the server\",\n\t\tArgs:               cobra.NoArgs,\n\t\tPersistentPreRunE:  cmd.InitBackendContext,\n\t\tPersistentPostRunE: cmd.CloseDBContext,\n\t\tRunE: func(c *cobra.Command, _ []string) error {\n\t\t\tctx := c.Context()\n\t\t\tcfg := config.DefaultConfig()\n\t\t\tif cfg.Exist() {\n\t\t\t\tif err := cfg.ParseFile(); err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"parse config file: %w\", err)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err := cfg.WriteConfig(); err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"write config file: %w\", err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif err := cfg.ParseEnv(); err != nil {\n\t\t\t\treturn fmt.Errorf(\"parse environment variables: %w\", err)\n\t\t\t}\n\n\t\t\t// Create custom hooks directory if it doesn't exist\n\t\t\tcustomHooksPath := filepath.Join(cfg.DataPath, \"hooks\")\n\t\t\tif _, err := os.Stat(customHooksPath); err != nil && os.IsNotExist(err) {\n\t\t\t\tos.MkdirAll(customHooksPath, os.ModePerm) //nolint: errcheck\n\t\t\t\t// Generate update hook example without executable permissions\n\t\t\t\thookPath := filepath.Join(customHooksPath, \"update.sample\")\n\t\t\t\t//nolint: gosec\n\t\t\t\tif err := os.WriteFile(hookPath, []byte(updateHookExample), 0o744); err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"failed to generate update hook example: %w\", err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Create log directory if it doesn't exist\n\t\t\tlogPath := filepath.Join(cfg.DataPath, \"log\")\n\t\t\tif _, err := os.Stat(logPath); err != nil && os.IsNotExist(err) {\n\t\t\t\tos.MkdirAll(logPath, os.ModePerm) //nolint: errcheck\n\t\t\t}\n\n\t\t\tdb := db.FromContext(ctx)\n\t\t\tif err := migrate.Migrate(ctx, db); err != nil {\n\t\t\t\treturn fmt.Errorf(\"migration error: %w\", err)\n\t\t\t}\n\n\t\t\ts, err := NewServer(ctx)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"start server: %w\", err)\n\t\t\t}\n\n\t\t\tif syncHooks {\n\t\t\t\tbe := backend.FromContext(ctx)\n\t\t\t\tif err := cmd.InitializeHooks(ctx, cfg, be); err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"initialize hooks: %w\", err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlch := make(chan error, 1)\n\t\t\tdone := make(chan os.Signal, 1)\n\t\t\tdoneOnce := sync.OnceFunc(func() { close(done) })\n\n\t\t\tsignal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)\n\n\t\t\t// This endpoint is added for testing purposes\n\t\t\t// It allows us to stop the server from the test suite.\n\t\t\t// This is needed since Windows doesn't support signals.\n\t\t\tif testRun, _ := strconv.ParseBool(os.Getenv(\"SOFT_SERVE_TESTRUN\")); testRun {\n\t\t\t\th := s.HTTPServer.Server.Handler\n\t\t\t\ts.HTTPServer.Server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t\tif r.URL.Path == \"/__stop\" && r.Method == http.MethodHead {\n\t\t\t\t\t\tdoneOnce()\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\th.ServeHTTP(w, r)\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tgo func() {\n\t\t\t\tlch <- s.Start()\n\t\t\t\tdoneOnce()\n\t\t\t}()\n\n\t\t\tfor {\n\t\t\t\tselect {\n\t\t\t\tcase err := <-lch:\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"server error: %w\", err)\n\t\t\t\t\t}\n\t\t\t\tcase sig := <-done:\n\t\t\t\t\tif sig == syscall.SIGHUP {\n\t\t\t\t\t\ts.logger.Info(\"received SIGHUP signal, reloading TLS certificates if enabled\")\n\t\t\t\t\t\tif err := s.ReloadCertificates(); err != nil {\n\t\t\t\t\t\t\ts.logger.Error(\"failed to reload TLS certificates\", \"err\", err)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tctx, cancel := context.WithTimeout(ctx, 5*time.Second)\n\t\t\tdefer cancel()\n\t\t\tif err := s.Shutdown(ctx); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\treturn nil\n\t\t},\n\t}\n)\n\nfunc init() {\n\tCommand.Flags().BoolVarP(&syncHooks, \"sync-hooks\", \"\", false, \"synchronize hooks for all repositories before running the server\")\n}\n\nconst updateHookExample = `#!/bin/sh\n#\n# An example hook script to echo information about the push\n# and send it to the client.\n#\n# To enable this hook, rename this file to \"update\" and make it executable.\n\nrefname=\"$1\"\noldrev=\"$2\"\nnewrev=\"$3\"\n\n# Safety check\nif [ -z \"$GIT_DIR\" ]; then\n        echo \"Don't run this script from the command line.\" >&2\n        echo \" (if you want, you could supply GIT_DIR then run\" >&2\n        echo \"  $0 <ref> <oldrev> <newrev>)\" >&2\n        exit 1\nfi\n\nif [ -z \"$refname\" -o -z \"$oldrev\" -o -z \"$newrev\" ]; then\n        echo \"usage: $0 <ref> <oldrev> <newrev>\" >&2\n        exit 1\nfi\n\n# Check types\n# if $newrev is 0000...0000, it's a commit to delete a ref.\nzero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')\nif [ \"$newrev\" = \"$zero\" ]; then\n        newrev_type=delete\nelse\n        newrev_type=$(git cat-file -t $newrev)\nfi\n\necho \"Hi from Soft Serve update hook!\"\necho\necho \"Repository: $SOFT_SERVE_REPO_NAME\"\necho \"RefName: $refname\"\necho \"Change Type: $newrev_type\"\necho \"Old SHA1: $oldrev\"\necho \"New SHA1: $newrev\"\n\nexit 0\n`\n"
  },
  {
    "path": "cmd/soft/serve/server.go",
    "content": "package serve\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"charm.land/log/v2\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/cron\"\n\t\"github.com/charmbracelet/soft-serve/pkg/daemon\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/jobs\"\n\tsshsrv \"github.com/charmbracelet/soft-serve/pkg/ssh\"\n\t\"github.com/charmbracelet/soft-serve/pkg/stats\"\n\t\"github.com/charmbracelet/soft-serve/pkg/web\"\n\t\"github.com/charmbracelet/ssh\"\n\t\"golang.org/x/sync/errgroup\"\n)\n\n// Server is the Soft Serve server.\ntype Server struct {\n\tSSHServer   *sshsrv.SSHServer\n\tGitDaemon   *daemon.GitDaemon\n\tHTTPServer  *web.HTTPServer\n\tStatsServer *stats.StatsServer\n\tCertLoader  *CertReloader\n\tCron        *cron.Scheduler\n\tConfig      *config.Config\n\tBackend     *backend.Backend\n\tDB          *db.DB\n\n\tlogger *log.Logger\n\tctx    context.Context\n}\n\n// NewServer returns a new *Server configured to serve Soft Serve. The SSH\n// server key-pair will be created if none exists.\n// It expects a context with *backend.Backend, *db.DB, *log.Logger, and\n// *config.Config attached.\nfunc NewServer(ctx context.Context) (*Server, error) {\n\tvar err error\n\tcfg := config.FromContext(ctx)\n\tbe := backend.FromContext(ctx)\n\tdb := db.FromContext(ctx)\n\tlogger := log.FromContext(ctx).WithPrefix(\"server\")\n\tsrv := &Server{\n\t\tConfig:  cfg,\n\t\tBackend: be,\n\t\tDB:      db,\n\t\tlogger:  log.FromContext(ctx).WithPrefix(\"server\"),\n\t\tctx:     ctx,\n\t}\n\n\t// Add cron jobs.\n\tsched := cron.NewScheduler(ctx)\n\tfor n, j := range jobs.List() {\n\t\tid, err := sched.AddFunc(j.Runner.Spec(ctx), j.Runner.Func(ctx))\n\t\tif err != nil {\n\t\t\tlogger.Warn(\"error adding cron job\", \"job\", n, \"err\", err)\n\t\t}\n\n\t\tj.ID = id\n\t}\n\n\tsrv.Cron = sched\n\n\tsrv.SSHServer, err = sshsrv.NewSSHServer(ctx)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create ssh server: %w\", err)\n\t}\n\n\tsrv.GitDaemon, err = daemon.NewGitDaemon(ctx)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create git daemon: %w\", err)\n\t}\n\n\tsrv.HTTPServer, err = web.NewHTTPServer(ctx)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create http server: %w\", err)\n\t}\n\n\tsrv.StatsServer, err = stats.NewStatsServer(ctx)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create stats server: %w\", err)\n\t}\n\n\tif cfg.HTTP.TLSKeyPath != \"\" && cfg.HTTP.TLSCertPath != \"\" {\n\t\tsrv.CertLoader, err = NewCertReloader(cfg.HTTP.TLSCertPath, cfg.HTTP.TLSKeyPath, logger)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"create cert reloader: %w\", err)\n\t\t}\n\n\t\tsrv.HTTPServer.SetTLSConfig(&tls.Config{\n\t\t\tGetCertificate: srv.CertLoader.GetCertificateFunc(),\n\t\t})\n\t}\n\n\treturn srv, nil\n}\n\n// ReloadCertificates reloads the TLS certificates for the HTTP server.\nfunc (s *Server) ReloadCertificates() error {\n\tif s.CertLoader == nil {\n\t\treturn nil\n\t}\n\treturn s.CertLoader.Reload()\n}\n\n// Start starts the SSH server.\nfunc (s *Server) Start() error {\n\terrg, _ := errgroup.WithContext(s.ctx)\n\n\t// optionally start the SSH server\n\tif s.Config.SSH.Enabled {\n\t\terrg.Go(func() error {\n\t\t\ts.logger.Print(\"Starting SSH server\", \"addr\", s.Config.SSH.ListenAddr)\n\t\t\tif err := s.SSHServer.ListenAndServe(); !errors.Is(err, ssh.ErrServerClosed) {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn nil\n\t\t})\n\t}\n\n\t// optionally start the git daemon\n\tif s.Config.Git.Enabled {\n\t\terrg.Go(func() error {\n\t\t\ts.logger.Print(\"Starting Git daemon\", \"addr\", s.Config.Git.ListenAddr)\n\t\t\tif err := s.GitDaemon.ListenAndServe(); !errors.Is(err, daemon.ErrServerClosed) {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn nil\n\t\t})\n\t}\n\n\t// optionally start the HTTP server\n\tif s.Config.HTTP.Enabled {\n\t\terrg.Go(func() error {\n\t\t\ts.logger.Print(\"Starting HTTP server\", \"addr\", s.Config.HTTP.ListenAddr)\n\t\t\tif err := s.HTTPServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn nil\n\t\t})\n\t}\n\n\t// optionally start the Stats server\n\tif s.Config.Stats.Enabled {\n\t\terrg.Go(func() error {\n\t\t\ts.logger.Print(\"Starting Stats server\", \"addr\", s.Config.Stats.ListenAddr)\n\t\t\tif err := s.StatsServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn nil\n\t\t})\n\t}\n\n\terrg.Go(func() error {\n\t\ts.Cron.Start()\n\t\treturn nil\n\t})\n\treturn errg.Wait()\n}\n\n// Shutdown lets the server gracefully shutdown.\nfunc (s *Server) Shutdown(ctx context.Context) error {\n\terrg, ctx := errgroup.WithContext(ctx)\n\terrg.Go(func() error {\n\t\treturn s.GitDaemon.Shutdown(ctx)\n\t})\n\terrg.Go(func() error {\n\t\treturn s.HTTPServer.Shutdown(ctx)\n\t})\n\terrg.Go(func() error {\n\t\treturn s.SSHServer.Shutdown(ctx)\n\t})\n\terrg.Go(func() error {\n\t\treturn s.StatsServer.Shutdown(ctx)\n\t})\n\terrg.Go(func() error {\n\t\tfor _, j := range jobs.List() {\n\t\t\ts.Cron.Remove(j.ID)\n\t\t}\n\t\ts.Cron.Stop()\n\t\treturn nil\n\t})\n\t// defer s.DB.Close() // nolint: errcheck\n\treturn errg.Wait()\n}\n\n// Close closes the SSH server.\nfunc (s *Server) Close() error {\n\tvar errg errgroup.Group\n\terrg.Go(s.GitDaemon.Close)\n\terrg.Go(s.HTTPServer.Close)\n\terrg.Go(s.SSHServer.Close)\n\terrg.Go(s.StatsServer.Close)\n\terrg.Go(func() error {\n\t\ts.Cron.Stop()\n\t\treturn nil\n\t})\n\t// defer s.DB.Close() // nolint: errcheck\n\treturn errg.Wait()\n}\n"
  },
  {
    "path": "codecov.yml",
    "content": "coverage:\n  status:\n    project:\n      default:\n        target: 50%\n    patch:\n      default:\n        target: 30%\n"
  },
  {
    "path": "demo.tape",
    "content": "Set Width 1600\nSet Height 900\nSet FontSize 22\n\nOutput soft-serve.gif\nOutput soft-serve-frames/\n\nType \"ssh git.charm.sh\"\nSleep 1s\nEnter\nSleep 2s\nType@500ms \"jjj\"\nSleep 1s\nType@250ms \"kkk\"\nEnter\nSleep 1s\nDown@300ms 10\nSleep 1s\nTab@1s 2\nDown@300ms 3\nEnter\nDown@250ms 30\nSleep 1s\nType \"h\"\nSleep 1s\nTab@1s 4\nSleep 500ms\nDown@300ms 4\nEnter\nDown@300ms 2\nEnter\nDown\nSleep 1s\nEnter\nDown@250ms 50\nSleep 2.5s\nEscape\nSleep 2s\n"
  },
  {
    "path": "docker.md",
    "content": "# Running Soft-Serve with Docker\n\nThe official Soft Serve Docker images are available at [charmcli/soft-serve][docker]. Development and nightly builds are available at [ghcr.io/charmbracelet/soft-serve][ghcr]\n\n```sh\ndocker pull charmcli/soft-serve:latest\n```\n\nHere’s how you might run `soft-serve` as a container.  Keep in mind that\nrepositories are stored in the `/soft-serve` directory, so you’ll likely want\nto mount that directory as a volume in order keep your repositories backed up.\n\n```sh\ndocker run \\\n  --name=soft-serve \\\n  --volume /path/to/data:/soft-serve \\\n  --publish 23231:23231 \\\n  --publish 23232:23232 \\\n  --publish 23233:23233 \\\n  --publish 9418:9418 \\\n  -e SOFT_SERVE_INITIAL_ADMIN_KEYS=\"YOUR_ADMIN_KEY_HERE\" \\\n  --restart unless-stopped \\\n  charmcli/soft-serve:latest\n```\n\nOr by using docker-compose:\n\n```yaml\n---\nversion: \"3.1\"\nservices:\n  soft-serve:\n    image: charmcli/soft-serve:latest\n    container_name: soft-serve\n    volumes:\n      - /path/to/data:/soft-serve\n    ports:\n      - 23231:23231\n      - 23232:23232\n      - 23233:23233\n      - 9418:9418\n    environment:\n      SOFT_SERVE_INITIAL_ADMIN_KEYS: \"YOUR_ADMIN_KEY_HERE\"\n    restart: unless-stopped\n```\n\n[docker]: https://hub.docker.com/r/charmcli/soft-serve\n[ghcr]: https://github.com/charmbracelet/soft-serve/pkgs/container/soft-serve\n\n\n> **Warning**\n>\n> Make sure to run the image without a TTY, i.e.: do not use the `--tty`/`-t`\n> flags.\n\n\n***\n\nPart of [Charm](https://charm.sh).\n\n<a href=\"https://charm.sh/\"><img alt=\"The Charm logo\" src=\"https://stuff.charm.sh/charm-badge-unrounded.jpg\" width=\"400\"></a>\n\nCharm热爱开源 • Charm loves open source\n"
  },
  {
    "path": "git/attr.go",
    "content": "package git\n\nimport (\n\t\"math/rand\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n)\n\n// Attribute represents a Git attribute.\ntype Attribute struct {\n\tName  string\n\tValue string\n}\n\n// CheckAttributes checks the attributes of the given ref and path.\nfunc (r *Repository) CheckAttributes(ref *Reference, path string) ([]Attribute, error) {\n\trnd := rand.NewSource(time.Now().UnixNano())\n\tfn := \"soft-serve-index-\" + strconv.Itoa(rand.New(rnd).Int()) //nolint: gosec\n\ttmpindex := filepath.Join(os.TempDir(), fn)\n\n\tdefer os.Remove(tmpindex) //nolint: errcheck\n\n\treadTree := NewCommand(\"read-tree\", \"--reset\", \"-i\", ref.Name().String()).\n\t\tAddEnvs(\"GIT_INDEX_FILE=\" + tmpindex)\n\tif _, err := readTree.RunInDir(r.Path); err != nil {\n\t\treturn nil, err\n\t}\n\n\tcheckAttr := NewCommand(\"check-attr\", \"--cached\", \"-a\", \"--\", path).\n\t\tAddEnvs(\"GIT_INDEX_FILE=\" + tmpindex)\n\tout, err := checkAttr.RunInDir(r.Path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn parseAttributes(path, out), nil\n}\n\nfunc parseAttributes(path string, buf []byte) []Attribute {\n\tattrs := make([]Attribute, 0)\n\tfor _, line := range strings.Split(string(buf), \"\\n\") {\n\t\tif line == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tline = strings.TrimPrefix(line, path+\": \")\n\t\tparts := strings.SplitN(line, \": \", 2)\n\t\tif len(parts) != 2 {\n\t\t\tcontinue\n\t\t}\n\n\t\tattrs = append(attrs, Attribute{\n\t\t\tName:  parts[0],\n\t\t\tValue: parts[1],\n\t\t})\n\t}\n\n\treturn attrs\n}\n"
  },
  {
    "path": "git/attr_test.go",
    "content": "package git\n\nimport (\n\t\"testing\"\n\n\t\"github.com/matryer/is\"\n)\n\nfunc TestParseAttr(t *testing.T) {\n\tcases := []struct {\n\t\tin   string\n\t\tfile string\n\t\twant []Attribute\n\t}{\n\t\t{\n\t\t\tin:   \"org/example/MyClass.java: diff: java\\n\",\n\t\t\tfile: \"org/example/MyClass.java\",\n\t\t\twant: []Attribute{\n\t\t\t\t{\n\t\t\t\t\tName:  \"diff\",\n\t\t\t\t\tValue: \"java\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tin: `org/example/MyClass.java: crlf: unset\norg/example/MyClass.java: diff: java\norg/example/MyClass.java: myAttr: set`,\n\t\t\tfile: \"org/example/MyClass.java\",\n\t\t\twant: []Attribute{\n\t\t\t\t{\n\t\t\t\t\tName:  \"crlf\",\n\t\t\t\t\tValue: \"unset\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tName:  \"diff\",\n\t\t\t\t\tValue: \"java\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tName:  \"myAttr\",\n\t\t\t\t\tValue: \"set\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tin: `org/example/MyClass.java: diff: java\norg/example/MyClass.java: myAttr: set`,\n\t\t\tfile: \"org/example/MyClass.java\",\n\t\t\twant: []Attribute{\n\t\t\t\t{\n\t\t\t\t\tName:  \"diff\",\n\t\t\t\t\tValue: \"java\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tName:  \"myAttr\",\n\t\t\t\t\tValue: \"set\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tin:   `README: caveat: unspecified`,\n\t\t\tfile: \"README\",\n\t\t\twant: []Attribute{\n\t\t\t\t{\n\t\t\t\t\tName:  \"caveat\",\n\t\t\t\t\tValue: \"unspecified\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tin:   \"\",\n\t\t\tfile: \"foo\",\n\t\t\twant: []Attribute{},\n\t\t},\n\t\t{\n\t\t\tin:   \"\\n\",\n\t\t\tfile: \"foo\",\n\t\t\twant: []Attribute{},\n\t\t},\n\t}\n\n\tis := is.New(t)\n\tfor _, c := range cases {\n\t\tattrs := parseAttributes(c.file, []byte(c.in))\n\t\tif len(attrs) != len(c.want) {\n\t\t\tt.Fatalf(\"parseAttributes(%q, %q) = %v, want %v\", c.file, c.in, attrs, c.want)\n\t\t}\n\n\t\tis.Equal(attrs, c.want)\n\t}\n}\n"
  },
  {
    "path": "git/command.go",
    "content": "package git\n\nimport \"github.com/aymanbagabas/git-module\"\n\n// RunInDirOptions are options for RunInDir.\ntype RunInDirOptions = git.RunInDirOptions\n\n// NewCommand creates a new git command.\nfunc NewCommand(args ...string) *git.Command {\n\treturn git.NewCommand(args...)\n}\n"
  },
  {
    "path": "git/commit.go",
    "content": "package git\n\nimport (\n\t\"regexp\"\n\n\t\"github.com/aymanbagabas/git-module\"\n)\n\n// ZeroID is the zero hash.\nconst ZeroID = git.EmptyID\n\n// IsZeroHash returns whether the hash is a zero hash.\nfunc IsZeroHash(h string) bool {\n\tpattern := regexp.MustCompile(`^0{40,}$`)\n\treturn pattern.MatchString(h)\n}\n\n// Commit is a wrapper around git.Commit with helper methods.\ntype Commit = git.Commit\n\n// Commits is a list of commits.\ntype Commits []*Commit\n\n// Len implements sort.Interface.\nfunc (cl Commits) Len() int { return len(cl) }\n\n// Swap implements sort.Interface.\nfunc (cl Commits) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }\n\n// Less implements sort.Interface.\nfunc (cl Commits) Less(i, j int) bool {\n\treturn cl[i].Author.When.After(cl[j].Author.When)\n}\n"
  },
  {
    "path": "git/config.go",
    "content": "package git\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\n\tgcfg \"github.com/go-git/go-git/v5/plumbing/format/config\"\n)\n\n// Config returns the repository Git configuration.\nfunc (r *Repository) Config() (*gcfg.Config, error) {\n\tcp := filepath.Join(r.Path, \"config\")\n\tf, err := os.Open(cp)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdefer f.Close() //nolint: errcheck\n\td := gcfg.NewDecoder(f)\n\tcfg := gcfg.New()\n\tif err := d.Decode(cfg); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn cfg, nil\n}\n\n// SetConfig sets the repository Git configuration.\nfunc (r *Repository) SetConfig(cfg *gcfg.Config) error {\n\tcp := filepath.Join(r.Path, \"config\")\n\tf, err := os.Create(cp)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer f.Close() //nolint: errcheck\n\te := gcfg.NewEncoder(f)\n\treturn e.Encode(cfg)\n}\n"
  },
  {
    "path": "git/errors.go",
    "content": "package git\n\nimport (\n\t\"errors\"\n\n\t\"github.com/aymanbagabas/git-module\"\n)\n\nvar (\n\t// ErrFileNotFound is returned when a file is not found.\n\tErrFileNotFound = errors.New(\"file not found\")\n\t// ErrDirectoryNotFound is returned when a directory is not found.\n\tErrDirectoryNotFound = errors.New(\"directory not found\")\n\t// ErrReferenceNotExist is returned when a reference does not exist.\n\tErrReferenceNotExist = git.ErrReferenceNotExist\n\t// ErrRevisionNotExist is returned when a revision is not found.\n\tErrRevisionNotExist = git.ErrRevisionNotExist\n\t// ErrNotAGitRepository is returned when the given path is not a Git repository.\n\tErrNotAGitRepository = errors.New(\"not a git repository\")\n)\n"
  },
  {
    "path": "git/patch.go",
    "content": "package git\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"math\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/aymanbagabas/git-module\"\n\t\"github.com/dustin/go-humanize/english\"\n\t\"github.com/sergi/go-diff/diffmatchpatch\"\n)\n\n// DiffSection is a wrapper to git.DiffSection with helper methods.\ntype DiffSection struct {\n\t*git.DiffSection\n\n\tinitOnce sync.Once\n\tdmp      *diffmatchpatch.DiffMatchPatch\n}\n\n// diffFor computes inline diff for the given line.\nfunc (s *DiffSection) diffFor(line *git.DiffLine) string {\n\tfallback := line.Content\n\n\t// Find equivalent diff line, ignore when not found.\n\tvar diff1, diff2 string\n\tswitch line.Type {\n\tcase git.DiffLineAdd:\n\t\tcompareLine := s.Line(git.DiffLineDelete, line.RightLine)\n\t\tif compareLine == nil {\n\t\t\treturn fallback\n\t\t}\n\n\t\tdiff1 = compareLine.Content\n\t\tdiff2 = line.Content\n\n\tcase git.DiffLineDelete:\n\t\tcompareLine := s.Line(git.DiffLineAdd, line.LeftLine)\n\t\tif compareLine == nil {\n\t\t\treturn fallback\n\t\t}\n\n\t\tdiff1 = line.Content\n\t\tdiff2 = compareLine.Content\n\n\tdefault:\n\t\treturn fallback\n\t}\n\n\ts.initOnce.Do(func() {\n\t\ts.dmp = diffmatchpatch.New()\n\t\ts.dmp.DiffEditCost = 100\n\t})\n\n\tdiffs := s.dmp.DiffMain(diff1[1:], diff2[1:], true)\n\tdiffs = s.dmp.DiffCleanupEfficiency(diffs)\n\n\treturn diffsToString(diffs, line.Type)\n}\n\nfunc diffsToString(diffs []diffmatchpatch.Diff, lineType git.DiffLineType) string {\n\tbuf := bytes.NewBuffer(nil)\n\n\t// Reproduce signs which are cutted for inline diff before.\n\tswitch lineType {\n\tcase git.DiffLineAdd:\n\t\tbuf.WriteByte('+')\n\tcase git.DiffLineDelete:\n\t\tbuf.WriteByte('-')\n\t}\n\n\tfor i := range diffs {\n\t\tswitch {\n\t\tcase diffs[i].Type == diffmatchpatch.DiffInsert && lineType == git.DiffLineAdd:\n\t\t\tbuf.WriteString(diffs[i].Text)\n\t\tcase diffs[i].Type == diffmatchpatch.DiffDelete && lineType == git.DiffLineDelete:\n\t\t\tbuf.WriteString(diffs[i].Text)\n\t\tcase diffs[i].Type == diffmatchpatch.DiffEqual:\n\t\t\tbuf.WriteString(diffs[i].Text)\n\t\t}\n\t}\n\n\treturn buf.String()\n}\n\n// DiffFile is a wrapper to git.DiffFile with helper methods.\ntype DiffFile struct {\n\t*git.DiffFile\n\tSections []*DiffSection\n}\n\n// DiffFileChange represents a file diff.\ntype DiffFileChange struct {\n\thash string\n\tname string\n\tmode git.EntryMode\n}\n\n// Hash returns the diff file hash.\nfunc (f *DiffFileChange) Hash() string {\n\treturn f.hash\n}\n\n// Name returns the diff name.\nfunc (f *DiffFileChange) Name() string {\n\treturn f.name\n}\n\n// Mode returns the diff file mode.\nfunc (f *DiffFileChange) Mode() git.EntryMode {\n\treturn f.mode\n}\n\n// Files returns the diff files.\nfunc (f *DiffFile) Files() (from *DiffFileChange, to *DiffFileChange) {\n\tif f.OldIndex != ZeroID {\n\t\tfrom = &DiffFileChange{\n\t\t\thash: f.OldIndex,\n\t\t\tname: f.OldName(),\n\t\t\tmode: f.OldMode(),\n\t\t}\n\t}\n\tif f.Index != ZeroID {\n\t\tto = &DiffFileChange{\n\t\t\thash: f.Index,\n\t\t\tname: f.Name,\n\t\t\tmode: f.Mode(),\n\t\t}\n\t}\n\treturn\n}\n\n// FileStats\ntype FileStats []*DiffFile\n\n// String returns a string representation of file stats.\nfunc (fs FileStats) String() string {\n\treturn printStats(fs)\n}\n\nfunc printStats(stats FileStats) string {\n\tpadLength := float64(len(\" \"))\n\tnewlineLength := float64(len(\"\\n\"))\n\tseparatorLength := float64(len(\"|\"))\n\t// Soft line length limit. The text length calculation below excludes\n\t// length of the change number. Adding that would take it closer to 80,\n\t// but probably not more than 80, until it's a huge number.\n\tlineLength := 72.0\n\n\t// Get the longest filename and longest total change.\n\tvar longestLength float64\n\tvar longestTotalChange float64\n\tfor _, fs := range stats {\n\t\tif int(longestLength) < len(fs.Name) {\n\t\t\tlongestLength = float64(len(fs.Name))\n\t\t}\n\t\ttotalChange := fs.NumAdditions() + fs.NumDeletions()\n\t\tif int(longestTotalChange) < totalChange {\n\t\t\tlongestTotalChange = float64(totalChange)\n\t\t}\n\t}\n\n\t// Parts of the output:\n\t// <pad><filename><pad>|<pad><changeNumber><pad><+++/---><newline>\n\t// example: \" main.go | 10 +++++++--- \"\n\n\t// <pad><filename><pad>\n\tleftTextLength := padLength + longestLength + padLength\n\n\t// <pad><number><pad><+++++/-----><newline>\n\t// Excluding number length here.\n\trightTextLength := padLength + padLength + newlineLength\n\n\ttotalTextArea := leftTextLength + separatorLength + rightTextLength\n\theightOfHistogram := lineLength - totalTextArea\n\n\t// Scale the histogram.\n\tvar scaleFactor float64\n\tif longestTotalChange > heightOfHistogram {\n\t\t// Scale down to heightOfHistogram.\n\t\tscaleFactor = longestTotalChange / heightOfHistogram\n\t} else {\n\t\tscaleFactor = 1.0\n\t}\n\n\ttaddc := 0\n\ttdelc := 0\n\toutput := strings.Builder{}\n\tfor _, fs := range stats {\n\t\ttaddc += fs.NumAdditions()\n\t\ttdelc += fs.NumDeletions()\n\t\taddn := float64(fs.NumAdditions())\n\t\tdeln := float64(fs.NumDeletions())\n\t\taddc := int(math.Floor(addn / scaleFactor))\n\t\tdelc := int(math.Floor(deln / scaleFactor))\n\t\tif addc < 0 {\n\t\t\taddc = 0\n\t\t}\n\t\tif delc < 0 {\n\t\t\tdelc = 0\n\t\t}\n\t\tadds := strings.Repeat(\"+\", addc)\n\t\tdels := strings.Repeat(\"-\", delc)\n\t\tdiffLines := fmt.Sprint(fs.NumAdditions() + fs.NumDeletions())\n\t\ttotalDiffLines := fmt.Sprint(int(longestTotalChange))\n\t\tfmt.Fprintf(&output, \"%s | %s %s%s\\n\",\n\t\t\tfs.Name+strings.Repeat(\" \", int(longestLength)-len(fs.Name)),\n\t\t\tstrings.Repeat(\" \", len(totalDiffLines)-len(diffLines))+diffLines,\n\t\t\tadds,\n\t\t\tdels)\n\t}\n\tfiles := len(stats)\n\tfc := fmt.Sprintf(\"%s changed\", english.Plural(files, \"file\", \"\"))\n\tins := fmt.Sprintf(\"%s(+)\", english.Plural(taddc, \"insertion\", \"\"))\n\tdels := fmt.Sprintf(\"%s(-)\", english.Plural(tdelc, \"deletion\", \"\"))\n\tfmt.Fprint(&output, fc)\n\tif taddc > 0 {\n\t\tfmt.Fprintf(&output, \", %s\", ins)\n\t}\n\tif tdelc > 0 {\n\t\tfmt.Fprintf(&output, \", %s\", dels)\n\t}\n\tfmt.Fprint(&output, \"\\n\")\n\n\treturn output.String()\n}\n\n// Diff is a wrapper around git.Diff with helper methods.\ntype Diff struct {\n\t*git.Diff\n\tFiles []*DiffFile\n}\n\n// FileStats returns the diff file stats.\nfunc (d *Diff) Stats() FileStats {\n\treturn d.Files\n}\n\nconst (\n\tdstPrefix = \"b/\"\n\tsrcPrefix = \"a/\"\n)\n\nfunc appendPathLines(lines []string, fromPath, toPath string, isBinary bool) []string {\n\tif isBinary {\n\t\treturn append(lines,\n\t\t\tfmt.Sprintf(\"Binary files %s and %s differ\", fromPath, toPath),\n\t\t)\n\t}\n\treturn append(lines,\n\t\tfmt.Sprintf(\"--- %s\", fromPath),\n\t\tfmt.Sprintf(\"+++ %s\", toPath),\n\t)\n}\n\nfunc writeFilePatchHeader(sb *strings.Builder, filePatch *DiffFile) {\n\tfrom, to := filePatch.Files()\n\tif from == nil && to == nil {\n\t\treturn\n\t}\n\tisBinary := filePatch.IsBinary()\n\n\tvar lines []string\n\tswitch {\n\tcase from != nil && to != nil:\n\t\thashEquals := from.Hash() == to.Hash()\n\t\tlines = append(lines,\n\t\t\tfmt.Sprintf(\"diff --git %s%s %s%s\",\n\t\t\t\tsrcPrefix, from.Name(), dstPrefix, to.Name()),\n\t\t)\n\t\tif from.Mode() != to.Mode() {\n\t\t\tlines = append(lines,\n\t\t\t\tfmt.Sprintf(\"old mode %o\", from.Mode()),\n\t\t\t\tfmt.Sprintf(\"new mode %o\", to.Mode()),\n\t\t\t)\n\t\t}\n\t\tif from.Name() != to.Name() {\n\t\t\tlines = append(lines,\n\t\t\t\tfmt.Sprintf(\"rename from %s\", from.Name()),\n\t\t\t\tfmt.Sprintf(\"rename to %s\", to.Name()),\n\t\t\t)\n\t\t}\n\t\tif from.Mode() != to.Mode() && !hashEquals {\n\t\t\tlines = append(lines,\n\t\t\t\tfmt.Sprintf(\"index %s..%s\", from.Hash(), to.Hash()),\n\t\t\t)\n\t\t} else if !hashEquals {\n\t\t\tlines = append(lines,\n\t\t\t\tfmt.Sprintf(\"index %s..%s %o\", from.Hash(), to.Hash(), from.Mode()),\n\t\t\t)\n\t\t}\n\t\tif !hashEquals {\n\t\t\tlines = appendPathLines(lines, srcPrefix+from.Name(), dstPrefix+to.Name(), isBinary)\n\t\t}\n\tcase from == nil:\n\t\tlines = append(lines,\n\t\t\tfmt.Sprintf(\"diff --git %s %s\", srcPrefix+to.Name(), dstPrefix+to.Name()),\n\t\t\tfmt.Sprintf(\"new file mode %o\", to.Mode()),\n\t\t\tfmt.Sprintf(\"index %s..%s\", ZeroID, to.Hash()),\n\t\t)\n\t\tlines = appendPathLines(lines, \"/dev/null\", dstPrefix+to.Name(), isBinary)\n\tcase to == nil:\n\t\tlines = append(lines,\n\t\t\tfmt.Sprintf(\"diff --git %s %s\", srcPrefix+from.Name(), dstPrefix+from.Name()),\n\t\t\tfmt.Sprintf(\"deleted file mode %o\", from.Mode()),\n\t\t\tfmt.Sprintf(\"index %s..%s\", from.Hash(), ZeroID),\n\t\t)\n\t\tlines = appendPathLines(lines, srcPrefix+from.Name(), \"/dev/null\", isBinary)\n\t}\n\n\tsb.WriteString(lines[0])\n\tfor _, line := range lines[1:] {\n\t\tsb.WriteByte('\\n')\n\t\tsb.WriteString(line)\n\t}\n\tsb.WriteByte('\\n')\n}\n\n// Patch returns the diff as a patch.\nfunc (d *Diff) Patch() string {\n\tvar p strings.Builder\n\tfor _, f := range d.Files {\n\t\twriteFilePatchHeader(&p, f)\n\t\tfor _, s := range f.Sections {\n\t\t\tfor _, l := range s.Lines {\n\t\t\t\tp.WriteString(s.diffFor(l))\n\t\t\t\tp.WriteString(\"\\n\")\n\t\t\t}\n\t\t}\n\t}\n\treturn p.String()\n}\n\nfunc toDiff(ddiff *git.Diff) *Diff {\n\tfiles := make([]*DiffFile, 0, len(ddiff.Files))\n\tfor _, df := range ddiff.Files {\n\t\tsections := make([]*DiffSection, 0, len(df.Sections))\n\t\tfor _, ds := range df.Sections {\n\t\t\tsections = append(sections, &DiffSection{\n\t\t\t\tDiffSection: ds,\n\t\t\t})\n\t\t}\n\t\tfiles = append(files, &DiffFile{\n\t\t\tDiffFile: df,\n\t\t\tSections: sections,\n\t\t})\n\t}\n\tdiff := &Diff{\n\t\tDiff:  ddiff,\n\t\tFiles: files,\n\t}\n\treturn diff\n}\n"
  },
  {
    "path": "git/reference.go",
    "content": "package git\n\nimport (\n\t\"strings\"\n\n\t\"github.com/aymanbagabas/git-module\"\n)\n\nconst (\n\t// HEAD represents the name of the HEAD reference.\n\tHEAD = \"HEAD\"\n\t// RefsHeads represents the prefix for branch references.\n\tRefsHeads = git.RefsHeads\n\t// RefsTags represents the prefix for tag references.\n\tRefsTags = git.RefsTags\n)\n\n// Reference is a wrapper around git.Reference with helper methods.\ntype Reference struct {\n\t*git.Reference\n\tpath string // repo path\n}\n\n// ReferenceName is a Refspec wrapper.\ntype ReferenceName string\n\n// String returns the reference name i.e. refs/heads/master.\nfunc (r ReferenceName) String() string {\n\treturn string(r)\n}\n\n// Short returns the short name of the reference i.e. master.\nfunc (r ReferenceName) Short() string {\n\treturn git.RefShortName(string(r))\n}\n\n// Name returns the reference name i.e. refs/heads/master.\nfunc (r *Reference) Name() ReferenceName {\n\treturn ReferenceName(r.Refspec)\n}\n\n// IsBranch returns true if the reference is a branch.\nfunc (r *Reference) IsBranch() bool {\n\treturn strings.HasPrefix(r.Refspec, git.RefsHeads)\n}\n\n// IsTag returns true if the reference is a tag.\nfunc (r *Reference) IsTag() bool {\n\treturn strings.HasPrefix(r.Refspec, git.RefsTags)\n}\n"
  },
  {
    "path": "git/repo.go",
    "content": "package git\n\nimport (\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/aymanbagabas/git-module\"\n)\n\nvar (\n\t// DiffMaxFile is the maximum number of files to show in a diff.\n\tDiffMaxFiles = 1000\n\t// DiffMaxFileLines is the maximum number of lines to show in a file diff.\n\tDiffMaxFileLines = 1000\n\t// DiffMaxLineChars is the maximum number of characters to show in a line diff.\n\tDiffMaxLineChars = 1000\n)\n\n// Repository is a wrapper around git.Repository with helper methods.\ntype Repository struct {\n\t*git.Repository\n\tPath   string\n\tIsBare bool\n}\n\n// Clone clones a repository.\nfunc Clone(src, dst string, opts ...git.CloneOptions) error {\n\treturn git.Clone(src, dst, opts...)\n}\n\n// Init initializes and opens a new git repository.\nfunc Init(path string, bare bool) (*Repository, error) {\n\tif bare {\n\t\tpath = strings.TrimSuffix(path, \".git\") + \".git\"\n\t}\n\n\terr := git.Init(path, git.InitOptions{Bare: bare})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn Open(path)\n}\n\nfunc gitDir(r *git.Repository) (string, error) {\n\treturn r.RevParse(\"--git-dir\")\n}\n\n// Open opens a git repository at the given path.\nfunc Open(path string) (*Repository, error) {\n\trepo, err := git.Open(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tgp, err := gitDir(repo)\n\tif err != nil || (gp != \".\" && gp != \".git\") {\n\t\treturn nil, ErrNotAGitRepository\n\t}\n\treturn &Repository{\n\t\tRepository: repo,\n\t\tPath:       path,\n\t\tIsBare:     gp == \".\",\n\t}, nil\n}\n\n// HEAD returns the HEAD reference for a repository.\nfunc (r *Repository) HEAD() (*Reference, error) {\n\trn, err := r.Repository.SymbolicRef(git.SymbolicRefOptions{Name: \"HEAD\"})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\thash, err := r.ShowRefVerify(rn)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &Reference{\n\t\tReference: &git.Reference{\n\t\t\tID:      hash,\n\t\t\tRefspec: rn,\n\t\t},\n\t\tpath: r.Path,\n\t}, nil\n}\n\n// References returns the references for a repository.\nfunc (r *Repository) References() ([]*Reference, error) {\n\trefs, err := r.ShowRef()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\trrefs := make([]*Reference, 0, len(refs))\n\tfor _, ref := range refs {\n\t\trrefs = append(rrefs, &Reference{\n\t\t\tReference: ref,\n\t\t\tpath:      r.Path,\n\t\t})\n\t}\n\treturn rrefs, nil\n}\n\n// LsTree returns the tree for the given reference.\nfunc (r *Repository) LsTree(ref string) (*Tree, error) {\n\ttree, err := r.Repository.LsTree(ref)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &Tree{\n\t\tTree:       tree,\n\t\tPath:       \"\",\n\t\tRepository: r,\n\t}, nil\n}\n\n// Tree returns the tree for the given reference.\nfunc (r *Repository) Tree(ref *Reference) (*Tree, error) {\n\tif ref == nil {\n\t\trref, err := r.HEAD()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tref = rref\n\t}\n\treturn r.LsTree(ref.ID)\n}\n\n// TreePath returns the tree for the given path.\nfunc (r *Repository) TreePath(ref *Reference, path string) (*Tree, error) {\n\tpath = filepath.Clean(path)\n\tif path == \".\" {\n\t\tpath = \"\"\n\t}\n\tif path == \"\" {\n\t\treturn r.Tree(ref)\n\t}\n\tt, err := r.Tree(ref)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn t.SubTree(path)\n}\n\n// Diff returns the diff for the given commit.\nfunc (r *Repository) Diff(commit *Commit) (*Diff, error) {\n\tdiff, err := r.Repository.Diff(commit.ID.String(), DiffMaxFiles, DiffMaxFileLines, DiffMaxLineChars, git.DiffOptions{\n\t\tCommandOptions: git.CommandOptions{\n\t\t\tEnvs: []string{\"GIT_CONFIG_GLOBAL=/dev/null\"},\n\t\t},\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn toDiff(diff), nil\n}\n\n// Patch returns the patch for the given reference.\nfunc (r *Repository) Patch(commit *Commit) (string, error) {\n\tdiff, err := r.Diff(commit)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn diff.Patch(), err\n}\n\n// CountCommits returns the number of commits in the repository.\nfunc (r *Repository) CountCommits(ref *Reference) (int64, error) {\n\treturn r.RevListCount([]string{ref.Name().String()})\n}\n\n// CommitsByPage returns the commits for a given page and size.\nfunc (r *Repository) CommitsByPage(ref *Reference, page, size int) (Commits, error) {\n\tcs, err := r.Repository.CommitsByPage(ref.Name().String(), page, size)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcommits := make(Commits, len(cs))\n\tcopy(commits, cs)\n\treturn commits, nil\n}\n\n// SymbolicRef returns or updates the symbolic reference for the given name.\n// Both name and ref can be empty.\nfunc (r *Repository) SymbolicRef(name string, ref string, opts ...git.SymbolicRefOptions) (string, error) {\n\tvar opt git.SymbolicRefOptions\n\tif len(opts) > 0 {\n\t\topt = opts[0]\n\t}\n\n\topt.Name = name\n\topt.Ref = ref\n\treturn r.Repository.SymbolicRef(opt)\n}\n"
  },
  {
    "path": "git/server.go",
    "content": "package git\n\nimport (\n\t\"context\"\n\n\t\"github.com/aymanbagabas/git-module\"\n)\n\n// UpdateServerInfo updates the server info file for the given repo path.\nfunc UpdateServerInfo(ctx context.Context, path string) error {\n\tif !isGitDir(path) {\n\t\treturn ErrNotAGitRepository\n\t}\n\n\tcmd := git.NewCommand(\"update-server-info\").WithContext(ctx).WithTimeout(-1)\n\t_, err := cmd.RunInDir(path)\n\treturn err\n}\n"
  },
  {
    "path": "git/stash.go",
    "content": "package git\n\nimport \"github.com/aymanbagabas/git-module\"\n\n// StashDiff returns the diff of the given stash index.\nfunc (r *Repository) StashDiff(index int) (*Diff, error) {\n\tdiff, err := r.Repository.StashDiff(index, DiffMaxFiles, DiffMaxFileLines, DiffMaxLineChars, git.DiffOptions{\n\t\tCommandOptions: git.CommandOptions{\n\t\t\tEnvs: []string{\"GIT_CONFIG_GLOBAL=/dev/null\"},\n\t\t},\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn toDiff(diff), nil\n}\n"
  },
  {
    "path": "git/tag.go",
    "content": "package git\n\nimport \"github.com/aymanbagabas/git-module\"\n\n// Tag is a git tag.\ntype Tag = git.Tag\n"
  },
  {
    "path": "git/tree.go",
    "content": "package git\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"io\"\n\t\"io/fs\"\n\t\"path/filepath\"\n\t\"sort\"\n\n\t\"github.com/aymanbagabas/git-module\"\n)\n\n// Tree is a wrapper around git.Tree with helper methods.\ntype Tree struct {\n\t*git.Tree\n\tPath       string\n\tRepository *Repository\n}\n\n// TreeEntry is a wrapper around git.TreeEntry with helper methods.\ntype TreeEntry struct {\n\t*git.TreeEntry\n\t// path is the full path of the file\n\tpath string\n}\n\n// Entries is a wrapper around git.Entries.\ntype Entries []*TreeEntry\n\nvar sorters = []func(t1, t2 *TreeEntry) bool{\n\tfunc(t1, t2 *TreeEntry) bool {\n\t\treturn (t1.IsTree() || t1.IsCommit()) && !t2.IsTree() && !t2.IsCommit()\n\t},\n\tfunc(t1, t2 *TreeEntry) bool {\n\t\treturn t1.Name() < t2.Name()\n\t},\n}\n\n// Len implements sort.Interface.\nfunc (es Entries) Len() int { return len(es) }\n\n// Swap implements sort.Interface.\nfunc (es Entries) Swap(i, j int) { es[i], es[j] = es[j], es[i] }\n\n// Less implements sort.Interface.\nfunc (es Entries) Less(i, j int) bool {\n\tt1, t2 := es[i], es[j]\n\tvar k int\n\tfor k = 0; k < len(sorters)-1; k++ {\n\t\tsorter := sorters[k]\n\t\tswitch {\n\t\tcase sorter(t1, t2):\n\t\t\treturn true\n\t\tcase sorter(t2, t1):\n\t\t\treturn false\n\t\t}\n\t}\n\treturn sorters[k](t1, t2)\n}\n\n// Sort sorts the entries in the tree.\nfunc (es Entries) Sort() {\n\tsort.Sort(es)\n}\n\n// File is a wrapper around git.Blob with helper methods.\ntype File struct {\n\t*git.Blob\n\tEntry *TreeEntry\n}\n\n// Name returns the name of the file.\nfunc (f *File) Name() string {\n\treturn f.Entry.Name()\n}\n\n// Path returns the full path of the file.\nfunc (f *File) Path() string {\n\treturn f.Entry.path\n}\n\n// SubTree returns the sub-tree at the given path.\nfunc (t *Tree) SubTree(path string) (*Tree, error) {\n\ttree, err := t.Subtree(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &Tree{\n\t\tTree:       tree,\n\t\tPath:       path,\n\t\tRepository: t.Repository,\n\t}, nil\n}\n\n// Entries returns the entries in the tree.\nfunc (t *Tree) Entries() (Entries, error) {\n\tentries, err := t.Tree.Entries()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tret := make(Entries, len(entries))\n\tfor i, e := range entries {\n\t\tret[i] = &TreeEntry{\n\t\t\tTreeEntry: e,\n\t\t\tpath:      filepath.Join(t.Path, e.Name()),\n\t\t}\n\t}\n\treturn ret, nil\n}\n\n// TreeEntry returns the TreeEntry for the file path.\nfunc (t *Tree) TreeEntry(path string) (*TreeEntry, error) {\n\tentry, err := t.Tree.TreeEntry(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &TreeEntry{\n\t\tTreeEntry: entry,\n\t\tpath:      filepath.Join(t.Path, entry.Name()),\n\t}, nil\n}\n\nconst sniffLen = 8000\n\n// IsBinary detects if data is a binary value based on:\n// http://git.kernel.org/cgit/git/git.git/tree/xdiff-interface.c?id=HEAD#n198\nfunc IsBinary(r io.Reader) (bool, error) {\n\treader := bufio.NewReader(r)\n\tc := 0\n\tfor c < sniffLen {\n\t\tb, err := reader.ReadByte()\n\t\tif err == io.EOF {\n\t\t\tbreak\n\t\t}\n\t\tif err != nil {\n\t\t\treturn false, err\n\t\t}\n\n\t\tif b == byte(0) {\n\t\t\treturn true, nil\n\t\t}\n\n\t\tc++\n\t}\n\n\treturn false, nil\n}\n\n// IsBinary returns true if the file is binary.\nfunc (f *File) IsBinary() (bool, error) {\n\tstdout := new(bytes.Buffer)\n\tstderr := new(bytes.Buffer)\n\terr := f.Pipeline(stdout, stderr)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tr := bufio.NewReader(stdout)\n\treturn IsBinary(r)\n}\n\n// Mode returns the mode of the file in fs.FileMode format.\nfunc (e *TreeEntry) Mode() fs.FileMode {\n\tm := e.Blob().Mode()\n\tswitch m {\n\tcase git.EntryTree:\n\t\treturn fs.ModeDir | fs.ModePerm\n\tdefault:\n\t\treturn fs.FileMode(m) //nolint:gosec\n\t}\n}\n\n// File returns the file for the TreeEntry.\nfunc (e *TreeEntry) File() *File {\n\tb := e.Blob()\n\treturn &File{\n\t\tBlob:  b,\n\t\tEntry: e,\n\t}\n}\n\n// Contents returns the contents of the file.\nfunc (e *TreeEntry) Contents() ([]byte, error) {\n\treturn e.File().Contents()\n}\n\n// Contents returns the contents of the file.\nfunc (f *File) Contents() ([]byte, error) {\n\treturn f.Blob.Bytes()\n}\n"
  },
  {
    "path": "git/types.go",
    "content": "package git\n\nimport \"github.com/aymanbagabas/git-module\"\n\n// CommandOptions contain options for running a git command.\ntype CommandOptions = git.CommandOptions\n\n// CloneOptions contain options for cloning a repository.\ntype CloneOptions = git.CloneOptions\n"
  },
  {
    "path": "git/utils.go",
    "content": "package git\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/gobwas/glob\"\n)\n\n// LatestFile returns the contents of the first file at the specified path pattern in the repository and its file path.\nfunc LatestFile(repo *Repository, ref *Reference, pattern string) (string, string, error) {\n\tg := glob.MustCompile(pattern)\n\tdir := filepath.Dir(pattern)\n\tif ref == nil {\n\t\thead, err := repo.HEAD()\n\t\tif err != nil {\n\t\t\treturn \"\", \"\", err\n\t\t}\n\t\tref = head\n\t}\n\tt, err := repo.TreePath(ref, dir)\n\tif err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\tents, err := t.Entries()\n\tif err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\tfor _, e := range ents {\n\t\tte := e\n\t\tfp := filepath.Join(dir, te.Name())\n\t\tif te.IsTree() {\n\t\t\tcontinue\n\t\t}\n\t\tif g.Match(fp) {\n\t\t\tif te.IsSymlink() {\n\t\t\t\tbts, err := te.Contents()\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn \"\", \"\", err\n\t\t\t\t}\n\t\t\t\tfp = string(bts)\n\t\t\t\tte, err = t.TreeEntry(fp)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn \"\", \"\", err\n\t\t\t\t}\n\t\t\t}\n\t\t\tbts, err := te.Contents()\n\t\t\tif err != nil {\n\t\t\t\treturn \"\", \"\", err\n\t\t\t}\n\t\t\treturn string(bts), fp, nil\n\t\t}\n\t}\n\treturn \"\", \"\", ErrFileNotFound\n}\n\n// Returns true if path is a directory containing an `objects` directory and a\n// `HEAD` file.\nfunc isGitDir(path string) bool {\n\tstat, err := os.Stat(filepath.Join(path, \"objects\"))\n\tif err != nil {\n\t\treturn false\n\t}\n\tif !stat.IsDir() {\n\t\treturn false\n\t}\n\n\tstat, err = os.Stat(filepath.Join(path, \"HEAD\"))\n\tif err != nil {\n\t\treturn false\n\t}\n\tif stat.IsDir() {\n\t\treturn false\n\t}\n\n\treturn true\n}\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/charmbracelet/soft-serve\n\ngo 1.25.8\n\nrequire (\n\tcharm.land/bubbles/v2 v2.0.0\n\tcharm.land/bubbletea/v2 v2.0.2\n\tcharm.land/glamour/v2 v2.0.0\n\tcharm.land/lipgloss/v2 v2.0.2\n\tcharm.land/log/v2 v2.0.0\n\tcharm.land/wish/v2 v2.0.0\n\tgithub.com/alecthomas/chroma/v2 v2.23.1\n\tgithub.com/aymanbagabas/git-module v1.8.4-0.20250826192401-1f81c5471e53\n\tgithub.com/caarlos0/duration v0.0.0-20240108180406-5d492514f3c7\n\tgithub.com/caarlos0/env/v11 v11.4.0\n\tgithub.com/charmbracelet/colorprofile v0.4.3\n\tgithub.com/charmbracelet/git-lfs-transfer v0.1.1-0.20240708204110-bacbfdb68d92\n\tgithub.com/charmbracelet/keygen v0.5.4\n\tgithub.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309\n\tgithub.com/charmbracelet/x/ansi v0.11.6\n\tgithub.com/dustin/go-humanize v1.0.1\n\tgithub.com/go-git/go-git/v5 v5.17.0\n\tgithub.com/go-jose/go-jose/v3 v3.0.4\n\tgithub.com/gobwas/glob v0.2.3\n\tgithub.com/golang-jwt/jwt/v5 v5.3.1\n\tgithub.com/google/go-querystring v1.2.0\n\tgithub.com/google/uuid v1.6.0\n\tgithub.com/gorilla/handlers v1.5.2\n\tgithub.com/gorilla/mux v1.8.1\n\tgithub.com/hashicorp/golang-lru/v2 v2.0.7\n\tgithub.com/jmoiron/sqlx v1.4.0\n\tgithub.com/lib/pq v1.11.2\n\tgithub.com/lrstanley/bubblezone/v2 v2.0.0\n\tgithub.com/matryer/is v1.4.1\n\tgithub.com/muesli/mango-cobra v1.3.0\n\tgithub.com/muesli/reflow v0.3.0\n\tgithub.com/muesli/roff v0.1.0\n\tgithub.com/prometheus/client_golang v1.23.2\n\tgithub.com/robfig/cron/v3 v3.0.1\n\tgithub.com/rogpeppe/go-internal v1.14.1\n\tgithub.com/sergi/go-diff v1.4.0\n\tgithub.com/spf13/cobra v1.10.2\n\tgo.uber.org/automaxprocs v1.6.0\n\tgolang.org/x/crypto v0.49.0\n\tgolang.org/x/sync v0.20.0\n\tgopkg.in/yaml.v3 v3.0.1\n\tmodernc.org/sqlite v1.46.1\n)\n\nrequire (\n\tgithub.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect\n\tgithub.com/atotto/clipboard v0.1.4 // indirect\n\tgithub.com/aymerick/douceur v0.2.0 // indirect\n\tgithub.com/beorn7/perks v1.0.1 // indirect\n\tgithub.com/cespare/xxhash/v2 v2.3.0 // indirect\n\tgithub.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73 // indirect\n\tgithub.com/charmbracelet/x/conpty v0.1.1 // indirect\n\tgithub.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 // indirect\n\tgithub.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect\n\tgithub.com/charmbracelet/x/term v0.2.2 // indirect\n\tgithub.com/charmbracelet/x/termios v0.1.1 // indirect\n\tgithub.com/charmbracelet/x/windows v0.2.2 // indirect\n\tgithub.com/clipperhouse/displaywidth v0.11.0 // indirect\n\tgithub.com/clipperhouse/uax29/v2 v2.7.0 // indirect\n\tgithub.com/creack/pty v1.1.24 // indirect\n\tgithub.com/dlclark/regexp2 v1.11.5 // indirect\n\tgithub.com/felixge/httpsnoop v1.0.4 // indirect\n\tgithub.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1 // indirect\n\tgithub.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect\n\tgithub.com/go-logfmt/logfmt v0.6.1 // indirect\n\tgithub.com/gorilla/css v1.0.1 // indirect\n\tgithub.com/inconshreveable/mousetrap v1.1.0 // indirect\n\tgithub.com/lucasb-eyer/go-colorful v1.3.0 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/mattn/go-runewidth v0.0.20 // indirect\n\tgithub.com/mcuadros/go-version v0.0.0-20190830083331-035f6764e8d2 // indirect\n\tgithub.com/microcosm-cc/bluemonday v1.0.27 // indirect\n\tgithub.com/muesli/cancelreader v0.2.2 // indirect\n\tgithub.com/muesli/mango v0.2.0 // indirect\n\tgithub.com/muesli/mango-pflag v0.1.0 // indirect\n\tgithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect\n\tgithub.com/ncruces/go-strftime v1.0.0 // indirect\n\tgithub.com/prometheus/client_model v0.6.2 // indirect\n\tgithub.com/prometheus/common v0.66.1 // indirect\n\tgithub.com/prometheus/procfs v0.16.1 // indirect\n\tgithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect\n\tgithub.com/rivo/uniseg v0.4.7 // indirect\n\tgithub.com/sahilm/fuzzy v0.1.1 // indirect\n\tgithub.com/spf13/pflag v1.0.9 // indirect\n\tgithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect\n\tgithub.com/yuin/goldmark v1.7.8 // indirect\n\tgithub.com/yuin/goldmark-emoji v1.0.5 // indirect\n\tgo.yaml.in/yaml/v2 v2.4.2 // indirect\n\tgolang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect\n\tgolang.org/x/net v0.51.0 // indirect\n\tgolang.org/x/sys v0.42.0 // indirect\n\tgolang.org/x/text v0.35.0 // indirect\n\tgolang.org/x/tools v0.42.0 // indirect\n\tgoogle.golang.org/protobuf v1.36.8 // indirect\n\tgopkg.in/warnings.v0 v0.1.2 // indirect\n\tmodernc.org/libc v1.67.6 // indirect\n\tmodernc.org/mathutil v1.7.1 // indirect\n\tmodernc.org/memory v1.11.0 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=\ncharm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=\ncharm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=\ncharm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=\ncharm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U=\ncharm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w=\ncharm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs=\ncharm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=\ncharm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s=\ncharm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0=\ncharm.land/wish/v2 v2.0.0 h1:0vryoDz6G1SdJNIWSkExy88dLAs7H/w0x9y/cay1vno=\ncharm.land/wish/v2 v2.0.0/go.mod h1:B42DmuVdvQxz215H9aCsbrXVSuAInAqkHAnmwg0nKs8=\nfilippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=\nfilippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=\ngithub.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=\ngithub.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=\ngithub.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=\ngithub.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=\ngithub.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=\ngithub.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=\ngithub.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=\ngithub.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=\ngithub.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=\ngithub.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=\ngithub.com/aymanbagabas/git-module v1.8.4-0.20250826192401-1f81c5471e53 h1:KfKp+gVsQtuM9qb8Putvkx1jjAWqlvI1vdv5x9hdFoQ=\ngithub.com/aymanbagabas/git-module v1.8.4-0.20250826192401-1f81c5471e53/go.mod h1:d4gQ7/3/S2sPq4NnKdtAgUOVr6XtLpWFtxyVV5/+76U=\ngithub.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=\ngithub.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=\ngithub.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=\ngithub.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=\ngithub.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=\ngithub.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=\ngithub.com/caarlos0/duration v0.0.0-20240108180406-5d492514f3c7 h1:kJP/C2eL9DCKrCOlX6lPVmAUAb6U4u9xllgws1kP9ds=\ngithub.com/caarlos0/duration v0.0.0-20240108180406-5d492514f3c7/go.mod h1:mSkwb/eZEwOJJJ4tqAKiuhLIPe0e9+FKhlU0oMCpbf8=\ngithub.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoGAc=\ngithub.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=\ngithub.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=\ngithub.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=\ngithub.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=\ngithub.com/charmbracelet/git-lfs-transfer v0.1.1-0.20240708204110-bacbfdb68d92 h1:KtQlsiHfY3K4AoIEh0yUE/wCLHteZ9EzV1hKmx+p7U8=\ngithub.com/charmbracelet/git-lfs-transfer v0.1.1-0.20240708204110-bacbfdb68d92/go.mod h1:UrXUCm3xLQkq15fu7qlXHUMlrhdlXHoi13KH2Dfiits=\ngithub.com/charmbracelet/keygen v0.5.4 h1:XQYgf6UEaTGgQSSmiPpIQ78WfseNQp4Pz8N/c1OsrdA=\ngithub.com/charmbracelet/keygen v0.5.4/go.mod h1:t4oBRr41bvK7FaJsAaAQhhkUuHslzFXVjOBwA55CZNM=\ngithub.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 h1:dCVbCRRtg9+tsfiTXTp0WupDlHruAXyp+YoxGVofHHc=\ngithub.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309/go.mod h1:R9cISUs5kAH4Cq/rguNbSwcR+slE5Dfm8FEs//uoIGE=\ngithub.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73 h1:Af/L28Xh+pddhouT/6lJ7IAIYfu5tWJOB0iqt+mXsYM=\ngithub.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73/go.mod h1:E6/0abq9uG2SnM8IbLB9Y5SW09uIgfaFETk8aRzgXUQ=\ngithub.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=\ngithub.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=\ngithub.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=\ngithub.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=\ngithub.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 h1:4EG8pCHK5fa8dIxv97VHC8hdkJAz6QNm1WB9BuD/WhY=\ngithub.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7/go.mod h1:O2BTD/aMVQDmrvqroIO3fB6zXUuU07ZpVt21QTmZjRg=\ngithub.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=\ngithub.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=\ngithub.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=\ngithub.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=\ngithub.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=\ngithub.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=\ngithub.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=\ngithub.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=\ngithub.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=\ngithub.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=\ngithub.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=\ngithub.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=\ngithub.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=\ngithub.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=\ngithub.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=\ngithub.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=\ngithub.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=\ngithub.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=\ngithub.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=\ngithub.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=\ngithub.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=\ngithub.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1 h1:mtDjlmloH7ytdblogrMz1/8Hqua1y8B4ID+bh3rvod0=\ngithub.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1/go.mod h1:fenKRzpXDjNpsIBhuhUzvjCKlDjKam0boRAenTE0Q6A=\ngithub.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=\ngithub.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=\ngithub.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM=\ngithub.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI=\ngithub.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=\ngithub.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=\ngithub.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=\ngithub.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=\ngithub.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=\ngithub.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=\ngithub.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=\ngithub.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=\ngithub.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=\ngithub.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=\ngithub.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=\ngithub.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=\ngithub.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=\ngithub.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=\ngithub.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=\ngithub.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=\ngithub.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=\ngithub.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=\ngithub.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=\ngithub.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=\ngithub.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=\ngithub.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=\ngithub.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=\ngithub.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=\ngithub.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=\ngithub.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=\ngithub.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=\ngithub.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=\ngithub.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=\ngithub.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=\ngithub.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=\ngithub.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=\ngithub.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=\ngithub.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=\ngithub.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=\ngithub.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=\ngithub.com/lrstanley/bubblezone/v2 v2.0.0 h1:pMb9fHKs0slJF6OrzQ2hEgWusqyl9VU/S0UZ5hyh7ZA=\ngithub.com/lrstanley/bubblezone/v2 v2.0.0/go.mod h1:yV/QTjcm4Zu5cqvGvdHi7xVUfnB36w/SafOuDp57dgY=\ngithub.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=\ngithub.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=\ngithub.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=\ngithub.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=\ngithub.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=\ngithub.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=\ngithub.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=\ngithub.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=\ngithub.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo=\ngithub.com/mcuadros/go-version v0.0.0-20190830083331-035f6764e8d2 h1:YocNLcTBdEdvY3iDK6jfWXvEaM5OCKkjxPKoJRdB3Gg=\ngithub.com/mcuadros/go-version v0.0.0-20190830083331-035f6764e8d2/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo=\ngithub.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=\ngithub.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=\ngithub.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=\ngithub.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=\ngithub.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ=\ngithub.com/muesli/mango v0.2.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=\ngithub.com/muesli/mango-cobra v1.3.0 h1:vQy5GvPg3ndOSpduxutqFoINhWk3vD5K2dXo5E8pqec=\ngithub.com/muesli/mango-cobra v1.3.0/go.mod h1:Cj1ZrBu3806Qw7UjxnAUgE+7tllUBj1NCLQDwwGx19E=\ngithub.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg=\ngithub.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=\ngithub.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=\ngithub.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=\ngithub.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=\ngithub.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=\ngithub.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=\ngithub.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=\ngithub.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=\ngithub.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=\ngithub.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=\ngithub.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=\ngithub.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=\ngithub.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=\ngithub.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=\ngithub.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=\ngithub.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=\ngithub.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=\ngithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=\ngithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=\ngithub.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=\ngithub.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=\ngithub.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=\ngithub.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=\ngithub.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=\ngithub.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=\ngithub.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=\ngithub.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=\ngithub.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=\ngithub.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=\ngithub.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=\ngithub.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=\ngithub.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=\ngithub.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=\ngithub.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=\ngithub.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=\ngithub.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=\ngithub.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=\ngithub.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=\ngithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=\ngithub.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=\ngithub.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=\ngithub.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=\ngithub.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=\ngithub.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=\ngithub.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=\ngo.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=\ngo.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=\ngo.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=\ngo.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=\ngo.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=\ngo.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=\ngo.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=\ngolang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=\ngolang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=\ngolang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=\ngolang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=\ngolang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=\ngolang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=\ngolang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=\ngolang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=\ngolang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=\ngolang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=\ngolang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=\ngolang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=\ngolang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=\ngolang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=\ngolang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=\ngolang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=\ngolang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=\ngolang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=\ngolang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=\ngolang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=\ngolang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=\ngolang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=\ngolang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=\ngolang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=\ngolang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=\ngolang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=\ngolang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=\ngolang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=\ngolang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=\ngolang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngoogle.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=\ngoogle.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=\ngopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=\ngopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\nmodernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=\nmodernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=\nmodernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=\nmodernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=\nmodernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=\nmodernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=\nmodernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=\nmodernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=\nmodernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=\nmodernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=\nmodernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=\nmodernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=\nmodernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=\nmodernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=\nmodernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=\nmodernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=\nmodernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=\nmodernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=\nmodernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=\nmodernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=\nmodernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=\nmodernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=\nmodernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=\nmodernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=\nmodernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=\nmodernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=\nmodernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=\nmodernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=\n"
  },
  {
    "path": "pkg/access/access.go",
    "content": "package access\n\nimport (\n\t\"encoding\"\n\t\"errors\"\n)\n\n// AccessLevel is the level of access allowed to a repo.\ntype AccessLevel int //nolint: revive\n\nconst (\n\t// NoAccess does not allow access to the repo.\n\tNoAccess AccessLevel = iota\n\n\t// ReadOnlyAccess allows read-only access to the repo.\n\tReadOnlyAccess\n\n\t// ReadWriteAccess allows read and write access to the repo.\n\tReadWriteAccess\n\n\t// AdminAccess allows read, write, and admin access to the repo.\n\tAdminAccess\n)\n\n// String returns the string representation of the access level.\nfunc (a AccessLevel) String() string {\n\tswitch a {\n\tcase NoAccess:\n\t\treturn \"no-access\"\n\tcase ReadOnlyAccess:\n\t\treturn \"read-only\"\n\tcase ReadWriteAccess:\n\t\treturn \"read-write\"\n\tcase AdminAccess:\n\t\treturn \"admin-access\"\n\tdefault:\n\t\treturn \"unknown\"\n\t}\n}\n\n// ParseAccessLevel parses an access level string.\nfunc ParseAccessLevel(s string) AccessLevel {\n\tswitch s {\n\tcase \"no-access\":\n\t\treturn NoAccess\n\tcase \"read-only\":\n\t\treturn ReadOnlyAccess\n\tcase \"read-write\":\n\t\treturn ReadWriteAccess\n\tcase \"admin-access\":\n\t\treturn AdminAccess\n\tdefault:\n\t\treturn AccessLevel(-1)\n\t}\n}\n\nvar (\n\t_ encoding.TextMarshaler   = AccessLevel(0)\n\t_ encoding.TextUnmarshaler = (*AccessLevel)(nil)\n)\n\n// ErrInvalidAccessLevel is returned when an invalid access level is provided.\nvar ErrInvalidAccessLevel = errors.New(\"invalid access level\")\n\n// UnmarshalText implements encoding.TextUnmarshaler.\nfunc (a *AccessLevel) UnmarshalText(text []byte) error {\n\tl := ParseAccessLevel(string(text))\n\tif l < 0 {\n\t\treturn ErrInvalidAccessLevel\n\t}\n\n\t*a = l\n\n\treturn nil\n}\n\n// MarshalText implements encoding.TextMarshaler.\nfunc (a AccessLevel) MarshalText() (text []byte, err error) {\n\treturn []byte(a.String()), nil\n}\n"
  },
  {
    "path": "pkg/access/access_test.go",
    "content": "package access\n\nimport \"testing\"\n\nfunc TestParseAccessLevel(t *testing.T) {\n\tcases := []struct {\n\t\tin  string\n\t\tout AccessLevel\n\t}{\n\t\t{\"\", -1},\n\t\t{\"foo\", -1},\n\t\t{AdminAccess.String(), AdminAccess},\n\t\t{ReadOnlyAccess.String(), ReadOnlyAccess},\n\t\t{ReadWriteAccess.String(), ReadWriteAccess},\n\t\t{NoAccess.String(), NoAccess},\n\t}\n\n\tfor _, c := range cases {\n\t\tout := ParseAccessLevel(c.in)\n\t\tif out != c.out {\n\t\t\tt.Errorf(\"ParseAccessLevel(%q) => %d, want %d\", c.in, out, c.out)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/access/context.go",
    "content": "package access\n\nimport \"context\"\n\n// ContextKey is the context key for the access level.\nvar ContextKey = &struct{ string }{\"access\"}\n\n// FromContext returns the access level from the context.\nfunc FromContext(ctx context.Context) AccessLevel {\n\tif ac, ok := ctx.Value(ContextKey).(AccessLevel); ok {\n\t\treturn ac\n\t}\n\n\treturn -1\n}\n\n// WithContext returns a new context with the access level.\nfunc WithContext(ctx context.Context, ac AccessLevel) context.Context {\n\treturn context.WithValue(ctx, ContextKey, ac)\n}\n"
  },
  {
    "path": "pkg/access/context_test.go",
    "content": "package access\n\nimport (\n\t\"context\"\n\t\"testing\"\n)\n\nfunc TestGoodFromContext(t *testing.T) {\n\tctx := WithContext(context.TODO(), AdminAccess)\n\tif ac := FromContext(ctx); ac != AdminAccess {\n\t\tt.Errorf(\"FromContext(ctx) => %d, want %d\", ac, AdminAccess)\n\t}\n}\n\nfunc TestBadFromContext(t *testing.T) {\n\tctx := context.TODO()\n\tif ac := FromContext(ctx); ac != -1 {\n\t\tt.Errorf(\"FromContext(ctx) => %d, want %d\", ac, -1)\n\t}\n}\n"
  },
  {
    "path": "pkg/backend/access_token.go",
    "content": "package backend\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"time\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/utils\"\n)\n\n// CreateAccessToken creates an access token for user.\nfunc (b *Backend) CreateAccessToken(ctx context.Context, user proto.User, name string, expiresAt time.Time) (string, error) {\n\ttoken := GenerateToken()\n\ttokenHash := HashToken(token)\n\tname = utils.Sanitize(name)\n\n\tif err := b.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\t_, err := b.store.CreateAccessToken(ctx, tx, name, user.ID(), tokenHash, expiresAt)\n\t\tif err != nil {\n\t\t\treturn db.WrapError(err)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn token, nil\n}\n\n// DeleteAccessToken deletes an access token for a user.\nfunc (b *Backend) DeleteAccessToken(ctx context.Context, user proto.User, id int64) error {\n\terr := b.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\t_, err := b.store.GetAccessToken(ctx, tx, id)\n\t\tif err != nil {\n\t\t\treturn db.WrapError(err)\n\t\t}\n\n\t\tif err := b.store.DeleteAccessTokenForUser(ctx, tx, user.ID(), id); err != nil {\n\t\t\treturn db.WrapError(err)\n\t\t}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\tif errors.Is(err, db.ErrRecordNotFound) {\n\t\t\treturn proto.ErrTokenNotFound\n\t\t}\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// ListAccessTokens lists access tokens for a user.\nfunc (b *Backend) ListAccessTokens(ctx context.Context, user proto.User) ([]proto.AccessToken, error) {\n\taccessTokens, err := b.store.GetAccessTokensByUserID(ctx, b.db, user.ID())\n\tif err != nil {\n\t\treturn nil, db.WrapError(err)\n\t}\n\n\tvar tokens []proto.AccessToken\n\tfor _, t := range accessTokens {\n\t\ttoken := proto.AccessToken{\n\t\t\tID:        t.ID,\n\t\t\tName:      t.Name,\n\t\t\tTokenHash: t.Token,\n\t\t\tUserID:    t.UserID,\n\t\t\tCreatedAt: t.CreatedAt,\n\t\t}\n\t\tif t.ExpiresAt.Valid {\n\t\t\ttoken.ExpiresAt = t.ExpiresAt.Time\n\t\t}\n\n\t\ttokens = append(tokens, token)\n\t}\n\n\treturn tokens, nil\n}\n"
  },
  {
    "path": "pkg/backend/auth.go",
    "content": "package backend\n\nimport (\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\n\t\"charm.land/log/v2\"\n\t\"golang.org/x/crypto/bcrypt\"\n)\n\nconst saltySalt = \"salty-soft-serve\"\n\n// HashPassword hashes the password using bcrypt.\nfunc HashPassword(password string) (string, error) {\n\tcrypt, err := bcrypt.GenerateFromPassword([]byte(password+saltySalt), bcrypt.DefaultCost)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn string(crypt), nil\n}\n\n// VerifyPassword verifies the password against the hash.\nfunc VerifyPassword(password, hash string) bool {\n\terr := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password+saltySalt))\n\treturn err == nil\n}\n\n// GenerateToken returns a random unique token.\nfunc GenerateToken() string {\n\tbuf := make([]byte, 20)\n\tif _, err := rand.Read(buf); err != nil {\n\t\tlog.Error(\"unable to generate access token\")\n\t\treturn \"\"\n\t}\n\n\treturn \"ss_\" + hex.EncodeToString(buf)\n}\n\n// HashToken hashes the token using sha256.\nfunc HashToken(token string) string {\n\tsum := sha256.Sum256([]byte(token + saltySalt))\n\treturn hex.EncodeToString(sum[:])\n}\n"
  },
  {
    "path": "pkg/backend/auth_test.go",
    "content": "package backend\n\nimport \"testing\"\n\nfunc TestHashPassword(t *testing.T) {\n\thash, err := HashPassword(\"password\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif hash == \"\" {\n\t\tt.Fatal(\"hash is empty\")\n\t}\n}\n\nfunc TestVerifyPassword(t *testing.T) {\n\thash, err := HashPassword(\"password\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !VerifyPassword(\"password\", hash) {\n\t\tt.Fatal(\"password did not verify\")\n\t}\n}\n\nfunc TestGenerateToken(t *testing.T) {\n\ttoken := GenerateToken()\n\tif token == \"\" {\n\t\tt.Fatal(\"token is empty\")\n\t}\n}\n\nfunc TestHashToken(t *testing.T) {\n\ttoken := GenerateToken()\n\thash := HashToken(token)\n\tif hash == \"\" {\n\t\tt.Fatal(\"hash is empty\")\n\t}\n}\n"
  },
  {
    "path": "pkg/backend/backend.go",
    "content": "package backend\n\nimport (\n\t\"context\"\n\n\t\"charm.land/log/v2\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/store\"\n\t\"github.com/charmbracelet/soft-serve/pkg/task\"\n)\n\n// Backend is the Soft Serve backend that handles users, repositories, and\n// server settings management and operations.\ntype Backend struct {\n\tctx     context.Context\n\tcfg     *config.Config\n\tdb      *db.DB\n\tstore   store.Store\n\tlogger  *log.Logger\n\tcache   *cache\n\tmanager *task.Manager\n}\n\n// New returns a new Soft Serve backend.\nfunc New(ctx context.Context, cfg *config.Config, db *db.DB, st store.Store) *Backend {\n\tlogger := log.FromContext(ctx).WithPrefix(\"backend\")\n\tb := &Backend{\n\t\tctx:     ctx,\n\t\tcfg:     cfg,\n\t\tdb:      db,\n\t\tstore:   st,\n\t\tlogger:  logger,\n\t\tmanager: task.NewManager(ctx),\n\t}\n\n\t// TODO: implement a proper caching interface\n\tcache := newCache(b, 1000)\n\tb.cache = cache\n\n\treturn b\n}\n"
  },
  {
    "path": "pkg/backend/cache.go",
    "content": "package backend\n\nimport lru \"github.com/hashicorp/golang-lru/v2\"\n\n// TODO: implement a caching interface.\ntype cache struct {\n\tb     *Backend\n\trepos *lru.Cache[string, *repo]\n}\n\nfunc newCache(b *Backend, size int) *cache {\n\tif size <= 0 {\n\t\tsize = 1\n\t}\n\tc := &cache{b: b}\n\tcache, _ := lru.New[string, *repo](size)\n\tc.repos = cache\n\treturn c\n}\n\nfunc (c *cache) Get(repo string) (*repo, bool) {\n\treturn c.repos.Get(repo)\n}\n\nfunc (c *cache) Set(repo string, r *repo) {\n\tc.repos.Add(repo, r)\n}\n\nfunc (c *cache) Delete(repo string) {\n\tc.repos.Remove(repo)\n}\n\nfunc (c *cache) Len() int {\n\treturn c.repos.Len()\n}\n"
  },
  {
    "path": "pkg/backend/collab.go",
    "content": "package backend\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"strings\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/access\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db/models\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/utils\"\n\t\"github.com/charmbracelet/soft-serve/pkg/webhook\"\n)\n\n// AddCollaborator adds a collaborator to a repository.\n//\n// It implements backend.Backend.\nfunc (d *Backend) AddCollaborator(ctx context.Context, repo string, username string, level access.AccessLevel) error {\n\tusername = strings.ToLower(username)\n\tif err := utils.ValidateUsername(username); err != nil {\n\t\treturn err\n\t}\n\n\trepo = utils.SanitizeRepo(repo)\n\tr, err := d.Repository(ctx, repo)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err := db.WrapError(\n\t\td.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\t\treturn d.store.AddCollabByUsernameAndRepo(ctx, tx, username, repo, level)\n\t\t}),\n\t); err != nil {\n\t\tif errors.Is(err, db.ErrDuplicateKey) {\n\t\t\treturn proto.ErrCollaboratorExist\n\t\t}\n\n\t\treturn err\n\t}\n\n\twh, err := webhook.NewCollaboratorEvent(ctx, proto.UserFromContext(ctx), r, username, webhook.CollaboratorEventAdded)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn webhook.SendEvent(ctx, wh)\n}\n\n// Collaborators returns a list of collaborators for a repository.\n//\n// It implements backend.Backend.\nfunc (d *Backend) Collaborators(ctx context.Context, repo string) ([]string, error) {\n\trepo = utils.SanitizeRepo(repo)\n\tvar users []models.User\n\tif err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\tvar err error\n\t\tusers, err = d.store.ListCollabsByRepoAsUsers(ctx, tx, repo)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, db.WrapError(err)\n\t}\n\n\tvar usernames []string\n\tfor _, u := range users {\n\t\tusernames = append(usernames, u.Username)\n\t}\n\n\treturn usernames, nil\n}\n\n// IsCollaborator returns the access level and true if the user is a collaborator of the repository.\n//\n// It implements backend.Backend.\nfunc (d *Backend) IsCollaborator(ctx context.Context, repo string, username string) (access.AccessLevel, bool, error) {\n\tif username == \"\" {\n\t\treturn -1, false, nil\n\t}\n\n\trepo = utils.SanitizeRepo(repo)\n\tvar m models.Collab\n\tif err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\tvar err error\n\t\tm, err = d.store.GetCollabByUsernameAndRepo(ctx, tx, username, repo)\n\t\treturn err\n\t}); err != nil {\n\t\treturn -1, false, db.WrapError(err)\n\t}\n\n\treturn m.AccessLevel, m.ID > 0, nil\n}\n\n// RemoveCollaborator removes a collaborator from a repository.\n//\n// It implements backend.Backend.\nfunc (d *Backend) RemoveCollaborator(ctx context.Context, repo string, username string) error {\n\trepo = utils.SanitizeRepo(repo)\n\tr, err := d.Repository(ctx, repo)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\twh, err := webhook.NewCollaboratorEvent(ctx, proto.UserFromContext(ctx), r, username, webhook.CollaboratorEventRemoved)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err := db.WrapError(\n\t\td.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\t\treturn d.store.RemoveCollabByUsernameAndRepo(ctx, tx, username, repo)\n\t\t}),\n\t); err != nil {\n\t\tif errors.Is(err, db.ErrRecordNotFound) {\n\t\t\treturn proto.ErrCollaboratorNotFound\n\t\t}\n\n\t\treturn err\n\t}\n\n\treturn webhook.SendEvent(ctx, wh)\n}\n"
  },
  {
    "path": "pkg/backend/context.go",
    "content": "package backend\n\nimport \"context\"\n\n// ContextKey is the key for the backend in the context.\nvar ContextKey = &struct{ string }{\"backend\"}\n\n// FromContext returns the backend from a context.\nfunc FromContext(ctx context.Context) *Backend {\n\tif b, ok := ctx.Value(ContextKey).(*Backend); ok {\n\t\treturn b\n\t}\n\n\treturn nil\n}\n\n// WithContext returns a new context with the backend attached.\nfunc WithContext(ctx context.Context, b *Backend) context.Context {\n\treturn context.WithValue(ctx, ContextKey, b)\n}\n"
  },
  {
    "path": "pkg/backend/hooks.go",
    "content": "package backend\n\nimport (\n\t\"context\"\n\t\"io\"\n\t\"os\"\n\t\"sync\"\n\n\t\"github.com/charmbracelet/soft-serve/git\"\n\t\"github.com/charmbracelet/soft-serve/pkg/hooks\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/sshutils\"\n\t\"github.com/charmbracelet/soft-serve/pkg/webhook\"\n)\n\nvar _ hooks.Hooks = (*Backend)(nil)\n\n// PostReceive is called by the git post-receive hook.\n//\n// It implements Hooks.\nfunc (d *Backend) PostReceive(_ context.Context, _ io.Writer, _ io.Writer, repo string, args []hooks.HookArg) {\n\td.logger.Debug(\"post-receive hook called\", \"repo\", repo, \"args\", args)\n}\n\n// PreReceive is called by the git pre-receive hook.\n//\n// It implements Hooks.\nfunc (d *Backend) PreReceive(_ context.Context, _ io.Writer, _ io.Writer, repo string, args []hooks.HookArg) {\n\td.logger.Debug(\"pre-receive hook called\", \"repo\", repo, \"args\", args)\n}\n\n// Update is called by the git update hook.\n//\n// It implements Hooks.\nfunc (d *Backend) Update(ctx context.Context, _ io.Writer, _ io.Writer, repo string, arg hooks.HookArg) {\n\td.logger.Debug(\"update hook called\", \"repo\", repo, \"arg\", arg)\n\n\t// Find user\n\tvar user proto.User\n\tif pubkey := os.Getenv(\"SOFT_SERVE_PUBLIC_KEY\"); pubkey != \"\" {\n\t\tpk, _, err := sshutils.ParseAuthorizedKey(pubkey)\n\t\tif err != nil {\n\t\t\td.logger.Error(\"error parsing public key\", \"err\", err)\n\t\t\treturn\n\t\t}\n\n\t\tuser, err = d.UserByPublicKey(ctx, pk)\n\t\tif err != nil {\n\t\t\td.logger.Error(\"error finding user from public key\", \"key\", pubkey, \"err\", err)\n\t\t\treturn\n\t\t}\n\t} else if username := os.Getenv(\"SOFT_SERVE_USERNAME\"); username != \"\" {\n\t\tvar err error\n\t\tuser, err = d.User(ctx, username)\n\t\tif err != nil {\n\t\t\td.logger.Error(\"error finding user from username\", \"username\", username, \"err\", err)\n\t\t\treturn\n\t\t}\n\t} else {\n\t\td.logger.Error(\"error finding user\")\n\t\treturn\n\t}\n\n\t// Get repo\n\tr, err := d.Repository(ctx, repo)\n\tif err != nil {\n\t\td.logger.Error(\"error finding repository\", \"repo\", repo, \"err\", err)\n\t\treturn\n\t}\n\n\t// TODO: run this async\n\t// This would probably need something like an RPC server to communicate with the hook process.\n\tif git.IsZeroHash(arg.OldSha) || git.IsZeroHash(arg.NewSha) {\n\t\twh, err := webhook.NewBranchTagEvent(ctx, user, r, arg.RefName, arg.OldSha, arg.NewSha)\n\t\tif err != nil {\n\t\t\td.logger.Error(\"error creating branch_tag webhook\", \"err\", err)\n\t\t} else if err := webhook.SendEvent(ctx, wh); err != nil {\n\t\t\td.logger.Error(\"error sending branch_tag webhook\", \"err\", err)\n\t\t}\n\t}\n\twh, err := webhook.NewPushEvent(ctx, user, r, arg.RefName, arg.OldSha, arg.NewSha)\n\tif err != nil {\n\t\td.logger.Error(\"error creating push webhook\", \"err\", err)\n\t} else if err := webhook.SendEvent(ctx, wh); err != nil {\n\t\td.logger.Error(\"error sending push webhook\", \"err\", err)\n\t}\n}\n\n// PostUpdate is called by the git post-update hook.\n//\n// It implements Hooks.\nfunc (d *Backend) PostUpdate(ctx context.Context, _ io.Writer, _ io.Writer, repo string, args ...string) {\n\td.logger.Debug(\"post-update hook called\", \"repo\", repo, \"args\", args)\n\n\tvar wg sync.WaitGroup\n\n\t// Populate last-modified file.\n\twg.Add(1)\n\tgo func() {\n\t\tdefer wg.Done()\n\t\tif err := populateLastModified(ctx, d, repo); err != nil {\n\t\t\td.logger.Error(\"error populating last-modified\", \"repo\", repo, \"err\", err)\n\t\t\treturn\n\t\t}\n\t}()\n\n\twg.Wait()\n}\n\nfunc populateLastModified(ctx context.Context, d *Backend, name string) error {\n\tvar rr *repo\n\t_rr, err := d.Repository(ctx, name)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif r, ok := _rr.(*repo); ok {\n\t\trr = r\n\t} else {\n\t\treturn proto.ErrRepoNotFound\n\t}\n\n\tr, err := rr.Open()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tc, err := r.LatestCommitTime()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn rr.writeLastModified(c)\n}\n"
  },
  {
    "path": "pkg/backend/lfs.go",
    "content": "package backend\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"io\"\n\t\"path\"\n\t\"path/filepath\"\n\t\"strconv\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/lfs\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/storage\"\n\t\"github.com/charmbracelet/soft-serve/pkg/store\"\n)\n\n// StoreRepoMissingLFSObjects stores missing LFS objects for a repository.\nfunc StoreRepoMissingLFSObjects(ctx context.Context, repo proto.Repository, dbx *db.DB, store store.Store, lfsClient lfs.Client) error {\n\tcfg := config.FromContext(ctx)\n\trepoID := strconv.FormatInt(repo.ID(), 10)\n\tlfsRoot := filepath.Join(cfg.DataPath, \"lfs\", repoID)\n\n\t// TODO: support S3 storage\n\tstrg := storage.NewLocalStorage(lfsRoot)\n\tpointerChan := make(chan lfs.PointerBlob)\n\terrChan := make(chan error, 1)\n\tr, err := repo.Open()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tgo lfs.SearchPointerBlobs(ctx, r, pointerChan, errChan)\n\n\tdownload := func(pointers []lfs.Pointer) error {\n\t\treturn lfsClient.Download(ctx, pointers, func(p lfs.Pointer, content io.ReadCloser, objectError error) error {\n\t\t\tif objectError != nil {\n\t\t\t\treturn objectError\n\t\t\t}\n\n\t\t\tdefer content.Close() //nolint: errcheck\n\t\t\treturn dbx.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\t\t\tif err := store.CreateLFSObject(ctx, tx, repo.ID(), p.Oid, p.Size); err != nil {\n\t\t\t\t\treturn db.WrapError(err)\n\t\t\t\t}\n\n\t\t\t\t_, err := strg.Put(path.Join(\"objects\", p.RelativePath()), content)\n\t\t\t\treturn err\n\t\t\t})\n\t\t})\n\t}\n\n\tvar batch []lfs.Pointer\n\tfor pointer := range pointerChan {\n\t\tobj, err := store.GetLFSObjectByOid(ctx, dbx, repo.ID(), pointer.Oid)\n\t\tif err != nil && !errors.Is(err, db.ErrRecordNotFound) {\n\t\t\treturn db.WrapError(err)\n\t\t}\n\n\t\texist, err := strg.Exists(path.Join(\"objects\", pointer.RelativePath()))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif exist && obj.ID == 0 {\n\t\t\tif err := store.CreateLFSObject(ctx, dbx, repo.ID(), pointer.Oid, pointer.Size); err != nil {\n\t\t\t\treturn db.WrapError(err)\n\t\t\t}\n\t\t} else {\n\t\t\tbatch = append(batch, pointer.Pointer)\n\t\t\t// Limit batch requests to 20 objects\n\t\t\tif len(batch) >= 20 {\n\t\t\t\tif err := download(batch); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tbatch = nil\n\t\t\t}\n\t\t}\n\t}\n\n\tif err, ok := <-errChan; ok {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/backend/repo.go",
    "content": "package backend\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/charmbracelet/soft-serve/git\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db/models\"\n\t\"github.com/charmbracelet/soft-serve/pkg/hooks\"\n\t\"github.com/charmbracelet/soft-serve/pkg/lfs\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/storage\"\n\t\"github.com/charmbracelet/soft-serve/pkg/task\"\n\t\"github.com/charmbracelet/soft-serve/pkg/utils\"\n\t\"github.com/charmbracelet/soft-serve/pkg/webhook\"\n)\n\nfunc validateImportRemote(remote string) error {\n\tendpoint, err := lfs.NewEndpoint(remote)\n\tif err != nil || endpoint.Host == \"\" {\n\t\treturn proto.ErrInvalidRemote\n\t}\n\n\treturn nil\n}\n\n// CreateRepository creates a new repository.\n//\n// It implements backend.Backend.\nfunc (d *Backend) CreateRepository(ctx context.Context, name string, user proto.User, opts proto.RepositoryOptions) (proto.Repository, error) {\n\tname = utils.SanitizeRepo(name)\n\tif err := utils.ValidateRepo(name); err != nil {\n\t\treturn nil, err\n\t}\n\n\trp := filepath.Join(d.repoPath(name))\n\n\tvar userID int64\n\tif user != nil {\n\t\tuserID = user.ID()\n\t}\n\n\tif err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\tif err := d.store.CreateRepo(\n\t\t\tctx,\n\t\t\ttx,\n\t\t\tname,\n\t\t\tuserID,\n\t\t\topts.ProjectName,\n\t\t\topts.Description,\n\t\t\topts.Private,\n\t\t\topts.Hidden,\n\t\t\topts.Mirror,\n\t\t); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t_, err := git.Init(rp, true)\n\t\tif err != nil {\n\t\t\td.logger.Debug(\"failed to create repository\", \"err\", err)\n\t\t\treturn err\n\t\t}\n\n\t\tif err := os.WriteFile(filepath.Join(rp, \"description\"), []byte(opts.Description), fs.ModePerm); err != nil {\n\t\t\td.logger.Error(\"failed to write description\", \"repo\", name, \"err\", err)\n\t\t\treturn err\n\t\t}\n\n\t\tif !opts.Private {\n\t\t\tif err := os.WriteFile(filepath.Join(rp, \"git-daemon-export-ok\"), []byte{}, fs.ModePerm); err != nil {\n\t\t\t\td.logger.Error(\"failed to write git-daemon-export-ok\", \"repo\", name, \"err\", err)\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn hooks.GenerateHooks(ctx, d.cfg, name)\n\t}); err != nil {\n\t\td.logger.Debug(\"failed to create repository in database\", \"err\", err)\n\t\terr = db.WrapError(err)\n\t\tif errors.Is(err, db.ErrDuplicateKey) {\n\t\t\treturn nil, proto.ErrRepoExist\n\t\t}\n\n\t\treturn nil, err\n\t}\n\n\treturn d.Repository(ctx, name)\n}\n\n// ImportRepository imports a repository from remote.\n// XXX: This a expensive operation and should be run in a goroutine.\nfunc (d *Backend) ImportRepository(_ context.Context, name string, user proto.User, remote string, opts proto.RepositoryOptions) (proto.Repository, error) {\n\tname = utils.SanitizeRepo(name)\n\tif err := utils.ValidateRepo(name); err != nil {\n\t\treturn nil, err\n\t}\n\n\tremote = utils.Sanitize(remote)\n\tif err := validateImportRemote(remote); err != nil {\n\t\treturn nil, err\n\t}\n\n\trp := filepath.Join(d.repoPath(name))\n\n\ttid := \"import:\" + name\n\tif d.manager.Exists(tid) {\n\t\treturn nil, task.ErrAlreadyStarted\n\t}\n\n\tif _, err := os.Stat(rp); err == nil || os.IsExist(err) {\n\t\treturn nil, proto.ErrRepoExist\n\t}\n\n\tdone := make(chan error, 1)\n\trepoc := make(chan proto.Repository, 1)\n\td.logger.Info(\"importing repository\", \"name\", name, \"remote\", remote, \"path\", rp)\n\td.manager.Add(tid, func(ctx context.Context) (err error) {\n\t\tctx = proto.WithUserContext(ctx, user)\n\n\t\tcopts := git.CloneOptions{\n\t\t\tBare:   true,\n\t\t\tMirror: opts.Mirror,\n\t\t\tQuiet:  true,\n\t\t\tCommandOptions: git.CommandOptions{\n\t\t\t\tTimeout: -1,\n\t\t\t\tContext: ctx,\n\t\t\t\tEnvs: []string{\n\t\t\t\t\tfmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile=\"%s\" -o StrictHostKeyChecking=no -i \"%s\"`,\n\t\t\t\t\t\tfilepath.Join(d.cfg.DataPath, \"ssh\", \"known_hosts\"),\n\t\t\t\t\t\td.cfg.SSH.ClientKeyPath,\n\t\t\t\t\t),\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\n\t\tif err := git.Clone(remote, rp, copts); err != nil {\n\t\t\td.logger.Error(\"failed to clone repository\", \"err\", err, \"mirror\", opts.Mirror, \"remote\", remote, \"path\", rp)\n\t\t\t// Cleanup the mess!\n\t\t\tif rerr := os.RemoveAll(rp); rerr != nil {\n\t\t\t\terr = errors.Join(err, rerr)\n\t\t\t}\n\n\t\t\treturn err\n\t\t}\n\n\t\tr, err := d.CreateRepository(ctx, name, user, opts)\n\t\tif err != nil {\n\t\t\td.logger.Error(\"failed to create repository\", \"err\", err, \"name\", name)\n\t\t\treturn err\n\t\t}\n\n\t\tdefer func() {\n\t\t\tif err != nil {\n\t\t\t\tif rerr := d.DeleteRepository(ctx, name); rerr != nil {\n\t\t\t\t\td.logger.Error(\"failed to delete repository\", \"err\", rerr, \"name\", name)\n\t\t\t\t}\n\t\t\t}\n\t\t}()\n\n\t\trr, err := r.Open()\n\t\tif err != nil {\n\t\t\td.logger.Error(\"failed to open repository\", \"err\", err, \"path\", rp)\n\t\t\treturn err\n\t\t}\n\n\t\trepoc <- r\n\n\t\trcfg, err := rr.Config()\n\t\tif err != nil {\n\t\t\td.logger.Error(\"failed to get repository config\", \"err\", err, \"path\", rp)\n\t\t\treturn err\n\t\t}\n\n\t\tendpoint := remote\n\t\tif opts.LFSEndpoint != \"\" {\n\t\t\tendpoint = opts.LFSEndpoint\n\t\t}\n\n\t\trcfg.Section(\"lfs\").SetOption(\"url\", endpoint)\n\n\t\tif err := rr.SetConfig(rcfg); err != nil {\n\t\t\td.logger.Error(\"failed to set repository config\", \"err\", err, \"path\", rp)\n\t\t\treturn err\n\t\t}\n\n\t\tep, err := lfs.NewEndpoint(endpoint)\n\t\tif err != nil {\n\t\t\td.logger.Error(\"failed to create lfs endpoint\", \"err\", err, \"path\", rp)\n\t\t\treturn err\n\t\t}\n\n\t\tclient := lfs.NewClient(ep)\n\t\tif client == nil {\n\t\t\td.logger.Warn(\"failed to create lfs client: unsupported endpoint\", \"endpoint\", endpoint)\n\t\t\treturn nil\n\t\t}\n\n\t\tif err := StoreRepoMissingLFSObjects(ctx, r, d.db, d.store, client); err != nil {\n\t\t\td.logger.Error(\"failed to store missing lfs objects\", \"err\", err, \"path\", rp)\n\t\t\treturn err\n\t\t}\n\n\t\treturn nil\n\t})\n\n\tgo func() {\n\t\td.logger.Info(\"running import\", \"name\", name)\n\t\td.manager.Run(tid, done)\n\t}()\n\n\treturn <-repoc, <-done\n}\n\n// DeleteRepository deletes a repository.\n//\n// It implements backend.Backend.\nfunc (d *Backend) DeleteRepository(ctx context.Context, name string) error {\n\tname = utils.SanitizeRepo(name)\n\trp := filepath.Join(d.repoPath(name))\n\n\tuser := proto.UserFromContext(ctx)\n\tr, err := d.Repository(ctx, name)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// We create the webhook event before deleting the repository so we can\n\t// send the event after deleting the repository.\n\twh, err := webhook.NewRepositoryEvent(ctx, user, r, webhook.RepositoryEventActionDelete)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\t// Delete repo from cache\n\t\tdefer d.cache.Delete(name)\n\n\t\trepom, dberr := d.store.GetRepoByName(ctx, tx, name)\n\t\t_, ferr := os.Stat(rp)\n\t\tif dberr != nil && ferr != nil {\n\t\t\treturn proto.ErrRepoNotFound\n\t\t}\n\n\t\t// If the repo is not in the database but the directory exists, remove it\n\t\tif dberr != nil && ferr == nil {\n\t\t\treturn os.RemoveAll(rp)\n\t\t} else if dberr != nil {\n\t\t\treturn db.WrapError(dberr)\n\t\t}\n\n\t\trepoID := strconv.FormatInt(repom.ID, 10)\n\t\tstrg := storage.NewLocalStorage(filepath.Join(d.cfg.DataPath, \"lfs\", repoID))\n\t\tobjs, err := d.store.GetLFSObjectsByName(ctx, tx, name)\n\t\tif err != nil {\n\t\t\treturn db.WrapError(err)\n\t\t}\n\n\t\tfor _, obj := range objs {\n\t\t\tp := lfs.Pointer{\n\t\t\t\tOid:  obj.Oid,\n\t\t\t\tSize: obj.Size,\n\t\t\t}\n\n\t\t\td.logger.Debug(\"deleting lfs object\", \"repo\", name, \"oid\", obj.Oid)\n\t\t\tif err := strg.Delete(path.Join(\"objects\", p.RelativePath())); err != nil {\n\t\t\t\td.logger.Error(\"failed to delete lfs object\", \"repo\", name, \"err\", err, \"oid\", obj.Oid)\n\t\t\t}\n\t\t}\n\n\t\tif err := d.store.DeleteRepoByName(ctx, tx, name); err != nil {\n\t\t\treturn db.WrapError(err)\n\t\t}\n\n\t\treturn os.RemoveAll(rp)\n\t}); err != nil {\n\t\tif errors.Is(err, db.ErrRecordNotFound) {\n\t\t\treturn proto.ErrRepoNotFound\n\t\t}\n\n\t\treturn db.WrapError(err)\n\t}\n\n\treturn webhook.SendEvent(ctx, wh)\n}\n\n// DeleteUserRepositories deletes all user repositories.\nfunc (d *Backend) DeleteUserRepositories(ctx context.Context, username string) error {\n\tif err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\tuser, err := d.store.FindUserByUsername(ctx, tx, username)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\trepos, err := d.store.GetUserRepos(ctx, tx, user.ID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor _, repo := range repos {\n\t\t\tif err := d.DeleteRepository(ctx, repo.Name); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn db.WrapError(err)\n\t}\n\n\treturn nil\n}\n\n// RenameRepository renames a repository.\n//\n// It implements backend.Backend.\nfunc (d *Backend) RenameRepository(ctx context.Context, oldName string, newName string) error {\n\toldName = utils.SanitizeRepo(oldName)\n\tif err := utils.ValidateRepo(oldName); err != nil {\n\t\treturn err\n\t}\n\n\tnewName = utils.SanitizeRepo(newName)\n\tif err := utils.ValidateRepo(newName); err != nil {\n\t\treturn err\n\t}\n\n\tif oldName == newName {\n\t\treturn nil\n\t}\n\n\top := filepath.Join(d.repoPath(oldName))\n\tnp := filepath.Join(d.repoPath(newName))\n\tif _, err := os.Stat(op); err != nil {\n\t\treturn proto.ErrRepoNotFound\n\t}\n\n\tif _, err := os.Stat(np); err == nil {\n\t\treturn proto.ErrRepoExist\n\t}\n\n\tif err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\t// Delete cache\n\t\tdefer d.cache.Delete(oldName)\n\n\t\tif err := d.store.SetRepoNameByName(ctx, tx, oldName, newName); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Make sure the new repository parent directory exists.\n\t\tif err := os.MkdirAll(filepath.Dir(np), os.ModePerm); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn os.Rename(op, np)\n\t}); err != nil {\n\t\treturn db.WrapError(err)\n\t}\n\n\tuser := proto.UserFromContext(ctx)\n\trepo, err := d.Repository(ctx, newName)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\twh, err := webhook.NewRepositoryEvent(ctx, user, repo, webhook.RepositoryEventActionRename)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn webhook.SendEvent(ctx, wh)\n}\n\n// Repositories returns a list of repositories per page.\n//\n// It implements backend.Backend.\nfunc (d *Backend) Repositories(ctx context.Context) ([]proto.Repository, error) {\n\trepos := make([]proto.Repository, 0)\n\n\tif err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\tms, err := d.store.GetAllRepos(ctx, tx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor _, m := range ms {\n\t\t\tr := &repo{\n\t\t\t\tname: m.Name,\n\t\t\t\tpath: filepath.Join(d.repoPath(m.Name)),\n\t\t\t\trepo: m,\n\t\t\t}\n\n\t\t\t// Cache repositories\n\t\t\td.cache.Set(m.Name, r)\n\n\t\t\trepos = append(repos, r)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, db.WrapError(err)\n\t}\n\n\treturn repos, nil\n}\n\n// Repository returns a repository by name.\n//\n// It implements backend.Backend.\nfunc (d *Backend) Repository(ctx context.Context, name string) (proto.Repository, error) {\n\tvar m models.Repo\n\tname = utils.SanitizeRepo(name)\n\n\tif r, ok := d.cache.Get(name); ok && r != nil {\n\t\treturn r, nil\n\t}\n\n\trp := filepath.Join(d.repoPath(name))\n\tif _, err := os.Stat(rp); err != nil {\n\t\tif !errors.Is(err, fs.ErrNotExist) {\n\t\t\td.logger.Errorf(\"failed to stat repository path: %v\", err)\n\t\t}\n\t\treturn nil, proto.ErrRepoNotFound\n\t}\n\n\tif err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\tvar err error\n\t\tm, err = d.store.GetRepoByName(ctx, tx, name)\n\t\treturn db.WrapError(err)\n\t}); err != nil {\n\t\tif errors.Is(err, db.ErrRecordNotFound) {\n\t\t\treturn nil, proto.ErrRepoNotFound\n\t\t}\n\t\treturn nil, db.WrapError(err)\n\t}\n\n\tr := &repo{\n\t\tname: name,\n\t\tpath: rp,\n\t\trepo: m,\n\t}\n\n\t// Add to cache\n\td.cache.Set(name, r)\n\n\treturn r, nil\n}\n\n// Description returns the description of a repository.\n//\n// It implements backend.Backend.\nfunc (d *Backend) Description(ctx context.Context, name string) (string, error) {\n\tname = utils.SanitizeRepo(name)\n\tvar desc string\n\tif err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\tvar err error\n\t\tdesc, err = d.store.GetRepoDescriptionByName(ctx, tx, name)\n\t\treturn err\n\t}); err != nil {\n\t\treturn \"\", db.WrapError(err)\n\t}\n\n\treturn desc, nil\n}\n\n// IsMirror returns true if the repository is a mirror.\n//\n// It implements backend.Backend.\nfunc (d *Backend) IsMirror(ctx context.Context, name string) (bool, error) {\n\tname = utils.SanitizeRepo(name)\n\tvar mirror bool\n\tif err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\tvar err error\n\t\tmirror, err = d.store.GetRepoIsMirrorByName(ctx, tx, name)\n\t\treturn err\n\t}); err != nil {\n\t\treturn false, db.WrapError(err)\n\t}\n\treturn mirror, nil\n}\n\n// IsPrivate returns true if the repository is private.\n//\n// It implements backend.Backend.\nfunc (d *Backend) IsPrivate(ctx context.Context, name string) (bool, error) {\n\tname = utils.SanitizeRepo(name)\n\tvar private bool\n\tif err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\tvar err error\n\t\tprivate, err = d.store.GetRepoIsPrivateByName(ctx, tx, name)\n\t\treturn err\n\t}); err != nil {\n\t\treturn false, db.WrapError(err)\n\t}\n\n\treturn private, nil\n}\n\n// IsHidden returns true if the repository is hidden.\n//\n// It implements backend.Backend.\nfunc (d *Backend) IsHidden(ctx context.Context, name string) (bool, error) {\n\tname = utils.SanitizeRepo(name)\n\tvar hidden bool\n\tif err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\tvar err error\n\t\thidden, err = d.store.GetRepoIsHiddenByName(ctx, tx, name)\n\t\treturn err\n\t}); err != nil {\n\t\treturn false, db.WrapError(err)\n\t}\n\n\treturn hidden, nil\n}\n\n// ProjectName returns the project name of a repository.\n//\n// It implements backend.Backend.\nfunc (d *Backend) ProjectName(ctx context.Context, name string) (string, error) {\n\tname = utils.SanitizeRepo(name)\n\tvar pname string\n\tif err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\tvar err error\n\t\tpname, err = d.store.GetRepoProjectNameByName(ctx, tx, name)\n\t\treturn err\n\t}); err != nil {\n\t\treturn \"\", db.WrapError(err)\n\t}\n\n\treturn pname, nil\n}\n\n// SetHidden sets the hidden flag of a repository.\n//\n// It implements backend.Backend.\nfunc (d *Backend) SetHidden(ctx context.Context, name string, hidden bool) error {\n\tname = utils.SanitizeRepo(name)\n\n\t// Delete cache\n\td.cache.Delete(name)\n\n\treturn db.WrapError(d.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\treturn d.store.SetRepoIsHiddenByName(ctx, tx, name, hidden)\n\t}))\n}\n\n// SetDescription sets the description of a repository.\n//\n// It implements backend.Backend.\nfunc (d *Backend) SetDescription(ctx context.Context, name string, desc string) error {\n\tname = utils.SanitizeRepo(name)\n\tdesc = utils.Sanitize(desc)\n\trp := filepath.Join(d.repoPath(name))\n\n\t// Delete cache\n\td.cache.Delete(name)\n\n\treturn d.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\tif err := os.WriteFile(filepath.Join(rp, \"description\"), []byte(desc), fs.ModePerm); err != nil {\n\t\t\td.logger.Error(\"failed to write description\", \"repo\", name, \"err\", err)\n\t\t\treturn err\n\t\t}\n\n\t\treturn d.store.SetRepoDescriptionByName(ctx, tx, name, desc)\n\t})\n}\n\n// SetPrivate sets the private flag of a repository.\n//\n// It implements backend.Backend.\nfunc (d *Backend) SetPrivate(ctx context.Context, name string, private bool) error {\n\tname = utils.SanitizeRepo(name)\n\trp := filepath.Join(d.repoPath(name))\n\n\t// Delete cache\n\td.cache.Delete(name)\n\n\tif err := db.WrapError(\n\t\td.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\t\tfp := filepath.Join(rp, \"git-daemon-export-ok\")\n\t\t\tif !private {\n\t\t\t\tif err := os.WriteFile(fp, []byte{}, fs.ModePerm); err != nil {\n\t\t\t\t\td.logger.Error(\"failed to write git-daemon-export-ok\", \"repo\", name, \"err\", err)\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif _, err := os.Stat(fp); err == nil {\n\t\t\t\t\tif err := os.Remove(fp); err != nil {\n\t\t\t\t\t\td.logger.Error(\"failed to remove git-daemon-export-ok\", \"repo\", name, \"err\", err)\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn d.store.SetRepoIsPrivateByName(ctx, tx, name, private)\n\t\t}),\n\t); err != nil {\n\t\treturn err\n\t}\n\n\tuser := proto.UserFromContext(ctx)\n\trepo, err := d.Repository(ctx, name)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif repo.IsPrivate() != !private {\n\t\twh, err := webhook.NewRepositoryEvent(ctx, user, repo, webhook.RepositoryEventActionVisibilityChange)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := webhook.SendEvent(ctx, wh); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// SetProjectName sets the project name of a repository.\n//\n// It implements backend.Backend.\nfunc (d *Backend) SetProjectName(ctx context.Context, repo string, name string) error {\n\trepo = utils.SanitizeRepo(repo)\n\tname = utils.Sanitize(name)\n\n\t// Delete cache\n\td.cache.Delete(repo)\n\n\treturn db.WrapError(\n\t\td.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\t\treturn d.store.SetRepoProjectNameByName(ctx, tx, repo, name)\n\t\t}),\n\t)\n}\n\n// repoPath returns the path to a repository.\nfunc (d *Backend) repoPath(name string) string {\n\tname = utils.SanitizeRepo(name)\n\trn := strings.ReplaceAll(name, \"/\", string(os.PathSeparator))\n\treturn filepath.Join(filepath.Join(d.cfg.DataPath, \"repos\"), rn+\".git\")\n}\n\nvar _ proto.Repository = (*repo)(nil)\n\n// repo is a Git repository with metadata stored in a SQLite database.\ntype repo struct {\n\tname string\n\tpath string\n\trepo models.Repo\n}\n\n// ID returns the repository's ID.\n//\n// It implements proto.Repository.\nfunc (r *repo) ID() int64 {\n\treturn r.repo.ID\n}\n\n// UserID returns the repository's owner's user ID.\n// If the repository is not owned by anyone, it returns 0.\n//\n// It implements proto.Repository.\nfunc (r *repo) UserID() int64 {\n\tif r.repo.UserID.Valid {\n\t\treturn r.repo.UserID.Int64\n\t}\n\treturn 0\n}\n\n// Description returns the repository's description.\n//\n// It implements backend.Repository.\nfunc (r *repo) Description() string {\n\treturn r.repo.Description\n}\n\n// IsMirror returns whether the repository is a mirror.\n//\n// It implements backend.Repository.\nfunc (r *repo) IsMirror() bool {\n\treturn r.repo.Mirror\n}\n\n// IsPrivate returns whether the repository is private.\n//\n// It implements backend.Repository.\nfunc (r *repo) IsPrivate() bool {\n\treturn r.repo.Private\n}\n\n// Name returns the repository's name.\n//\n// It implements backend.Repository.\nfunc (r *repo) Name() string {\n\treturn r.name\n}\n\n// Open opens the repository.\n//\n// It implements backend.Repository.\nfunc (r *repo) Open() (*git.Repository, error) {\n\treturn git.Open(r.path)\n}\n\n// ProjectName returns the repository's project name.\n//\n// It implements backend.Repository.\nfunc (r *repo) ProjectName() string {\n\treturn r.repo.ProjectName\n}\n\n// IsHidden returns whether the repository is hidden.\n//\n// It implements backend.Repository.\nfunc (r *repo) IsHidden() bool {\n\treturn r.repo.Hidden\n}\n\n// CreatedAt returns the repository's creation time.\nfunc (r *repo) CreatedAt() time.Time {\n\treturn r.repo.CreatedAt\n}\n\n// UpdatedAt returns the repository's last update time.\nfunc (r *repo) UpdatedAt() time.Time {\n\t// Try to read the last modified time from the info directory.\n\tif t, err := readOneline(filepath.Join(r.path, \"info\", \"last-modified\")); err == nil {\n\t\tif t, err := time.Parse(time.RFC3339, t); err == nil {\n\t\t\treturn t\n\t\t}\n\t}\n\n\trr, err := git.Open(r.path)\n\tif err == nil {\n\t\tt, err := rr.LatestCommitTime()\n\t\tif err == nil {\n\t\t\treturn t\n\t\t}\n\t}\n\n\treturn r.repo.UpdatedAt\n}\n\nfunc (r *repo) writeLastModified(t time.Time) error {\n\tfp := filepath.Join(r.path, \"info\", \"last-modified\")\n\tif err := os.MkdirAll(filepath.Dir(fp), os.ModePerm); err != nil {\n\t\treturn err\n\t}\n\n\treturn os.WriteFile(fp, []byte(t.Format(time.RFC3339)), os.ModePerm) //nolint:gosec\n}\n\nfunc readOneline(path string) (string, error) {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tdefer f.Close() //nolint: errcheck\n\ts := bufio.NewScanner(f)\n\ts.Scan()\n\treturn s.Text(), s.Err()\n}\n"
  },
  {
    "path": "pkg/backend/settings.go",
    "content": "package backend\n\nimport (\n\t\"context\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/access\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n)\n\n// AllowKeyless returns whether or not keyless access is allowed.\n//\n// It implements backend.Backend.\nfunc (b *Backend) AllowKeyless(ctx context.Context) bool {\n\tvar allow bool\n\tif err := b.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\tvar err error\n\t\tallow, err = b.store.GetAllowKeylessAccess(ctx, tx)\n\t\treturn err\n\t}); err != nil {\n\t\treturn false\n\t}\n\n\treturn allow\n}\n\n// SetAllowKeyless sets whether or not keyless access is allowed.\n//\n// It implements backend.Backend.\nfunc (b *Backend) SetAllowKeyless(ctx context.Context, allow bool) error {\n\treturn b.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\treturn b.store.SetAllowKeylessAccess(ctx, tx, allow)\n\t})\n}\n\n// AnonAccess returns the level of anonymous access.\n//\n// It implements backend.Backend.\nfunc (b *Backend) AnonAccess(ctx context.Context) access.AccessLevel {\n\tvar level access.AccessLevel\n\tif err := b.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\tvar err error\n\t\tlevel, err = b.store.GetAnonAccess(ctx, tx)\n\t\treturn err\n\t}); err != nil {\n\t\treturn access.NoAccess\n\t}\n\n\treturn level\n}\n\n// SetAnonAccess sets the level of anonymous access.\n//\n// It implements backend.Backend.\nfunc (b *Backend) SetAnonAccess(ctx context.Context, level access.AccessLevel) error {\n\treturn b.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\treturn b.store.SetAnonAccess(ctx, tx, level)\n\t})\n}\n"
  },
  {
    "path": "pkg/backend/user.go",
    "content": "package backend\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/access\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db/models\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/sshutils\"\n\t\"github.com/charmbracelet/soft-serve/pkg/utils\"\n\t\"golang.org/x/crypto/ssh\"\n)\n\n// AccessLevel returns the access level of a user for a repository.\n//\n// It implements backend.Backend.\nfunc (d *Backend) AccessLevel(ctx context.Context, repo string, username string) access.AccessLevel {\n\tuser, _ := d.User(ctx, username)\n\treturn d.AccessLevelForUser(ctx, repo, user)\n}\n\n// AccessLevelByPublicKey returns the access level of a user's public key for a repository.\n//\n// It implements backend.Backend.\nfunc (d *Backend) AccessLevelByPublicKey(ctx context.Context, repo string, pk ssh.PublicKey) access.AccessLevel {\n\tfor _, k := range d.cfg.AdminKeys() {\n\t\tif sshutils.KeysEqual(pk, k) {\n\t\t\treturn access.AdminAccess\n\t\t}\n\t}\n\n\tuser, _ := d.UserByPublicKey(ctx, pk)\n\tif user != nil {\n\t\treturn d.AccessLevel(ctx, repo, user.Username())\n\t}\n\n\treturn d.AccessLevel(ctx, repo, \"\")\n}\n\n// AccessLevelForUser returns the access level of a user for a repository.\n// TODO: user repository ownership\nfunc (d *Backend) AccessLevelForUser(ctx context.Context, repo string, user proto.User) access.AccessLevel {\n\tvar username string\n\tanon := d.AnonAccess(ctx)\n\tif user != nil {\n\t\tusername = user.Username()\n\t}\n\n\t// If the user is an admin, they have admin access.\n\tif user != nil && user.IsAdmin() {\n\t\treturn access.AdminAccess\n\t}\n\n\t// If the repository exists, check if the user is a collaborator.\n\tr := proto.RepositoryFromContext(ctx)\n\tif r == nil {\n\t\tr, _ = d.Repository(ctx, repo)\n\t}\n\n\tif r != nil {\n\t\tif user != nil {\n\t\t\t// If the user is the owner, they have admin access.\n\t\t\tif r.UserID() == user.ID() {\n\t\t\t\treturn access.AdminAccess\n\t\t\t}\n\t\t}\n\n\t\t// If the user is a collaborator, they have return their access level.\n\t\tcollabAccess, isCollab, _ := d.IsCollaborator(ctx, repo, username)\n\t\tif isCollab {\n\t\t\tif anon > collabAccess {\n\t\t\t\treturn anon\n\t\t\t}\n\t\t\treturn collabAccess\n\t\t}\n\n\t\t// If the repository is private, the user has no access.\n\t\tif r.IsPrivate() {\n\t\t\treturn access.NoAccess\n\t\t}\n\n\t\t// Otherwise, the user has read-only access.\n\t\tif user == nil {\n\t\t\treturn anon\n\t\t}\n\n\t\treturn access.ReadOnlyAccess\n\t}\n\n\tif user != nil {\n\t\t// If the repository doesn't exist, the user has read/write access.\n\t\tif anon > access.ReadWriteAccess {\n\t\t\treturn anon\n\t\t}\n\n\t\treturn access.ReadWriteAccess\n\t}\n\n\t// If the user doesn't exist, give them the anonymous access level.\n\treturn anon\n}\n\n// User finds a user by username.\n//\n// It implements backend.Backend.\nfunc (d *Backend) User(ctx context.Context, username string) (proto.User, error) {\n\tusername = strings.ToLower(username)\n\tif err := utils.ValidateUsername(username); err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar m models.User\n\tvar pks []ssh.PublicKey\n\tif err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\tvar err error\n\t\tm, err = d.store.FindUserByUsername(ctx, tx, username)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tpks, err = d.store.ListPublicKeysByUserID(ctx, tx, m.ID)\n\t\treturn err\n\t}); err != nil {\n\t\terr = db.WrapError(err)\n\t\tif errors.Is(err, db.ErrRecordNotFound) {\n\t\t\treturn nil, proto.ErrUserNotFound\n\t\t}\n\t\td.logger.Error(\"error finding user\", \"username\", username, \"error\", err)\n\t\treturn nil, err\n\t}\n\n\treturn &user{\n\t\tuser:       m,\n\t\tpublicKeys: pks,\n\t}, nil\n}\n\n// UserByID finds a user by ID.\nfunc (d *Backend) UserByID(ctx context.Context, id int64) (proto.User, error) {\n\tvar m models.User\n\tvar pks []ssh.PublicKey\n\tif err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\tvar err error\n\t\tm, err = d.store.GetUserByID(ctx, tx, id)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tpks, err = d.store.ListPublicKeysByUserID(ctx, tx, m.ID)\n\t\treturn err\n\t}); err != nil {\n\t\terr = db.WrapError(err)\n\t\tif errors.Is(err, db.ErrRecordNotFound) {\n\t\t\treturn nil, proto.ErrUserNotFound\n\t\t}\n\t\td.logger.Error(\"error finding user\", \"id\", id, \"error\", err)\n\t\treturn nil, err\n\t}\n\n\treturn &user{\n\t\tuser:       m,\n\t\tpublicKeys: pks,\n\t}, nil\n}\n\n// UserByPublicKey finds a user by public key.\n//\n// It implements backend.Backend.\nfunc (d *Backend) UserByPublicKey(ctx context.Context, pk ssh.PublicKey) (proto.User, error) {\n\tvar m models.User\n\tvar pks []ssh.PublicKey\n\tif err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\tvar err error\n\t\tm, err = d.store.FindUserByPublicKey(ctx, tx, pk)\n\t\tif err != nil {\n\t\t\treturn db.WrapError(err)\n\t\t}\n\n\t\tpks, err = d.store.ListPublicKeysByUserID(ctx, tx, m.ID)\n\t\treturn err\n\t}); err != nil {\n\t\terr = db.WrapError(err)\n\t\tif errors.Is(err, db.ErrRecordNotFound) {\n\t\t\treturn nil, proto.ErrUserNotFound\n\t\t}\n\t\td.logger.Error(\"error finding user\", \"pk\", sshutils.MarshalAuthorizedKey(pk), \"error\", err)\n\t\treturn nil, err\n\t}\n\n\treturn &user{\n\t\tuser:       m,\n\t\tpublicKeys: pks,\n\t}, nil\n}\n\n// UserByAccessToken finds a user by access token.\n// This also validates the token for expiration and returns proto.ErrTokenExpired.\nfunc (d *Backend) UserByAccessToken(ctx context.Context, token string) (proto.User, error) {\n\tvar m models.User\n\tvar pks []ssh.PublicKey\n\ttoken = HashToken(token)\n\n\tif err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\tt, err := d.store.GetAccessTokenByToken(ctx, tx, token)\n\t\tif err != nil {\n\t\t\treturn db.WrapError(err)\n\t\t}\n\n\t\tif t.ExpiresAt.Valid && t.ExpiresAt.Time.Before(time.Now()) {\n\t\t\treturn proto.ErrTokenExpired\n\t\t}\n\n\t\tm, err = d.store.FindUserByAccessToken(ctx, tx, token)\n\t\tif err != nil {\n\t\t\treturn db.WrapError(err)\n\t\t}\n\n\t\tpks, err = d.store.ListPublicKeysByUserID(ctx, tx, m.ID)\n\t\treturn err\n\t}); err != nil {\n\t\terr = db.WrapError(err)\n\t\tif errors.Is(err, db.ErrRecordNotFound) {\n\t\t\treturn nil, proto.ErrUserNotFound\n\t\t}\n\t\td.logger.Error(\"failed to find user by access token\", \"err\", err, \"token\", token)\n\t\treturn nil, err\n\t}\n\n\treturn &user{\n\t\tuser:       m,\n\t\tpublicKeys: pks,\n\t}, nil\n}\n\n// Users returns all users.\n//\n// It implements backend.Backend.\nfunc (d *Backend) Users(ctx context.Context) ([]string, error) {\n\tvar users []string\n\tif err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\tms, err := d.store.GetAllUsers(ctx, tx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor _, m := range ms {\n\t\t\tusers = append(users, m.Username)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, db.WrapError(err)\n\t}\n\n\treturn users, nil\n}\n\n// AddPublicKey adds a public key to a user.\n//\n// It implements backend.Backend.\nfunc (d *Backend) AddPublicKey(ctx context.Context, username string, pk ssh.PublicKey) error {\n\tusername = strings.ToLower(username)\n\tif err := utils.ValidateUsername(username); err != nil {\n\t\treturn err\n\t}\n\n\treturn db.WrapError(\n\t\td.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\t\treturn d.store.AddPublicKeyByUsername(ctx, tx, username, pk)\n\t\t}),\n\t)\n}\n\n// CreateUser creates a new user.\n//\n// It implements backend.Backend.\nfunc (d *Backend) CreateUser(ctx context.Context, username string, opts proto.UserOptions) (proto.User, error) {\n\tusername = utils.Sanitize(username)\n\tusername = strings.ToLower(username)\n\tif err := utils.ValidateUsername(username); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\treturn d.store.CreateUser(ctx, tx, username, opts.Admin, opts.PublicKeys)\n\t}); err != nil {\n\t\treturn nil, db.WrapError(err)\n\t}\n\n\treturn d.User(ctx, username)\n}\n\n// DeleteUser deletes a user.\n//\n// It implements backend.Backend.\nfunc (d *Backend) DeleteUser(ctx context.Context, username string) error {\n\tusername = strings.ToLower(username)\n\tif err := utils.ValidateUsername(username); err != nil {\n\t\treturn err\n\t}\n\n\treturn d.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\tif err := d.store.DeleteUserByUsername(ctx, tx, username); err != nil {\n\t\t\treturn db.WrapError(err)\n\t\t}\n\n\t\treturn d.DeleteUserRepositories(ctx, username)\n\t})\n}\n\n// RemovePublicKey removes a public key from a user.\n//\n// It implements backend.Backend.\nfunc (d *Backend) RemovePublicKey(ctx context.Context, username string, pk ssh.PublicKey) error {\n\treturn db.WrapError(\n\t\td.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\t\treturn d.store.RemovePublicKeyByUsername(ctx, tx, username, pk)\n\t\t}),\n\t)\n}\n\n// ListPublicKeys lists the public keys of a user.\nfunc (d *Backend) ListPublicKeys(ctx context.Context, username string) ([]ssh.PublicKey, error) {\n\tusername = strings.ToLower(username)\n\tif err := utils.ValidateUsername(username); err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar keys []ssh.PublicKey\n\tif err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\tvar err error\n\t\tkeys, err = d.store.ListPublicKeysByUsername(ctx, tx, username)\n\t\treturn err\n\t}); err != nil {\n\t\treturn nil, db.WrapError(err)\n\t}\n\n\treturn keys, nil\n}\n\n// SetUsername sets the username of a user.\n//\n// It implements backend.Backend.\nfunc (d *Backend) SetUsername(ctx context.Context, username string, newUsername string) error {\n\tusername = strings.ToLower(username)\n\tif err := utils.ValidateUsername(username); err != nil {\n\t\treturn err\n\t}\n\n\treturn db.WrapError(\n\t\td.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\t\treturn d.store.SetUsernameByUsername(ctx, tx, username, newUsername)\n\t\t}),\n\t)\n}\n\n// SetAdmin sets the admin flag of a user.\n//\n// It implements backend.Backend.\nfunc (d *Backend) SetAdmin(ctx context.Context, username string, admin bool) error {\n\tusername = strings.ToLower(username)\n\tif err := utils.ValidateUsername(username); err != nil {\n\t\treturn err\n\t}\n\n\treturn db.WrapError(\n\t\td.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\t\treturn d.store.SetAdminByUsername(ctx, tx, username, admin)\n\t\t}),\n\t)\n}\n\n// SetPassword sets the password of a user.\nfunc (d *Backend) SetPassword(ctx context.Context, username string, rawPassword string) error {\n\tusername = strings.ToLower(username)\n\tif err := utils.ValidateUsername(username); err != nil {\n\t\treturn err\n\t}\n\n\tpassword, err := HashPassword(rawPassword)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn db.WrapError(\n\t\td.db.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\t\treturn d.store.SetUserPasswordByUsername(ctx, tx, username, password)\n\t\t}),\n\t)\n}\n\ntype user struct {\n\tuser       models.User\n\tpublicKeys []ssh.PublicKey\n}\n\nvar _ proto.User = (*user)(nil)\n\n// IsAdmin implements proto.User\nfunc (u *user) IsAdmin() bool {\n\treturn u.user.Admin\n}\n\n// PublicKeys implements proto.User\nfunc (u *user) PublicKeys() []ssh.PublicKey {\n\treturn u.publicKeys\n}\n\n// Username implements proto.User\nfunc (u *user) Username() string {\n\treturn u.user.Username\n}\n\n// ID implements proto.User.\nfunc (u *user) ID() int64 {\n\treturn u.user.ID\n}\n\n// Password implements proto.User.\nfunc (u *user) Password() string {\n\tif u.user.Password.Valid {\n\t\treturn u.user.Password.String\n\t}\n\n\treturn \"\"\n}\n"
  },
  {
    "path": "pkg/backend/utils.go",
    "content": "package backend\n\nimport (\n\t\"github.com/charmbracelet/soft-serve/git\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n)\n\n// LatestFile returns the contents of the latest file at the specified path in\n// the repository and its file path.\nfunc LatestFile(r proto.Repository, ref *git.Reference, pattern string) (string, string, error) {\n\trepo, err := r.Open()\n\tif err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\treturn git.LatestFile(repo, ref, pattern)\n}\n\n// Readme returns the repository's README.\nfunc Readme(r proto.Repository, ref *git.Reference) (readme string, path string, err error) {\n\tpattern := \"[rR][eE][aA][dD][mM][eE]*\"\n\treadme, path, err = LatestFile(r, ref, pattern)\n\treturn\n}\n"
  },
  {
    "path": "pkg/backend/webhooks.go",
    "content": "package backend\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\n\t\"charm.land/log/v2\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db/models\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/store\"\n\t\"github.com/charmbracelet/soft-serve/pkg/utils\"\n\t\"github.com/charmbracelet/soft-serve/pkg/webhook\"\n\t\"github.com/google/uuid\"\n)\n\n// CreateWebhook creates a webhook for a repository.\nfunc (b *Backend) CreateWebhook(ctx context.Context, repo proto.Repository, url string, contentType webhook.ContentType, secret string, events []webhook.Event, active bool) error {\n\tdbx := db.FromContext(ctx)\n\tdatastore := store.FromContext(ctx)\n\turl = utils.Sanitize(url)\n\n\t// Validate webhook URL to prevent SSRF attacks\n\tif err := webhook.ValidateWebhookURL(url); err != nil {\n\t\treturn err //nolint:wrapcheck\n\t}\n\n\treturn dbx.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\tlastID, err := datastore.CreateWebhook(ctx, tx, repo.ID(), url, secret, int(contentType), active)\n\t\tif err != nil {\n\t\t\treturn db.WrapError(err)\n\t\t}\n\n\t\tevs := make([]int, len(events))\n\t\tfor i, e := range events {\n\t\t\tevs[i] = int(e)\n\t\t}\n\t\tif err := datastore.CreateWebhookEvents(ctx, tx, lastID, evs); err != nil {\n\t\t\treturn db.WrapError(err)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\n// Webhook returns a webhook for a repository.\nfunc (b *Backend) Webhook(ctx context.Context, repo proto.Repository, id int64) (webhook.Hook, error) {\n\tdbx := db.FromContext(ctx)\n\tdatastore := store.FromContext(ctx)\n\n\tvar wh webhook.Hook\n\tif err := dbx.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\th, err := datastore.GetWebhookByID(ctx, tx, repo.ID(), id)\n\t\tif err != nil {\n\t\t\treturn db.WrapError(err)\n\t\t}\n\t\tevents, err := datastore.GetWebhookEventsByWebhookID(ctx, tx, id)\n\t\tif err != nil {\n\t\t\treturn db.WrapError(err)\n\t\t}\n\n\t\twh = webhook.Hook{\n\t\t\tWebhook:     h,\n\t\t\tContentType: webhook.ContentType(h.ContentType), //nolint:gosec\n\t\t\tEvents:      make([]webhook.Event, len(events)),\n\t\t}\n\t\tfor i, e := range events {\n\t\t\twh.Events[i] = webhook.Event(e.Event)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn webhook.Hook{}, db.WrapError(err)\n\t}\n\n\treturn wh, nil\n}\n\n// ListWebhooks lists webhooks for a repository.\nfunc (b *Backend) ListWebhooks(ctx context.Context, repo proto.Repository) ([]webhook.Hook, error) {\n\tdbx := db.FromContext(ctx)\n\tdatastore := store.FromContext(ctx)\n\n\tvar webhooks []models.Webhook\n\twebhookEvents := map[int64][]models.WebhookEvent{}\n\tif err := dbx.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\tvar err error\n\t\twebhooks, err = datastore.GetWebhooksByRepoID(ctx, tx, repo.ID())\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor _, h := range webhooks {\n\t\t\tevents, err := datastore.GetWebhookEventsByWebhookID(ctx, tx, h.ID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\twebhookEvents[h.ID] = events\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, db.WrapError(err)\n\t}\n\n\thooks := make([]webhook.Hook, len(webhooks))\n\tfor i, h := range webhooks {\n\t\tevents := make([]webhook.Event, len(webhookEvents[h.ID]))\n\t\tfor i, e := range webhookEvents[h.ID] {\n\t\t\tevents[i] = webhook.Event(e.Event)\n\t\t}\n\n\t\thooks[i] = webhook.Hook{\n\t\t\tWebhook:     h,\n\t\t\tContentType: webhook.ContentType(h.ContentType), //nolint:gosec\n\t\t\tEvents:      events,\n\t\t}\n\t}\n\n\treturn hooks, nil\n}\n\n// UpdateWebhook updates a webhook.\nfunc (b *Backend) UpdateWebhook(ctx context.Context, repo proto.Repository, id int64, url string, contentType webhook.ContentType, secret string, updatedEvents []webhook.Event, active bool) error {\n\tdbx := db.FromContext(ctx)\n\tdatastore := store.FromContext(ctx)\n\n\t// Validate webhook URL to prevent SSRF attacks\n\tif err := webhook.ValidateWebhookURL(url); err != nil {\n\t\treturn err\n\t}\n\n\treturn dbx.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\tif err := datastore.UpdateWebhookByID(ctx, tx, repo.ID(), id, url, secret, int(contentType), active); err != nil {\n\t\t\treturn db.WrapError(err)\n\t\t}\n\n\t\tcurrentEvents, err := datastore.GetWebhookEventsByWebhookID(ctx, tx, id)\n\t\tif err != nil {\n\t\t\treturn db.WrapError(err)\n\t\t}\n\n\t\t// Delete events that are no longer in the list.\n\t\ttoBeDeleted := make([]int64, 0)\n\t\tfor _, e := range currentEvents {\n\t\t\tfound := false\n\t\t\tfor _, ne := range updatedEvents {\n\t\t\t\tif int(ne) == e.Event {\n\t\t\t\t\tfound = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif !found {\n\t\t\t\ttoBeDeleted = append(toBeDeleted, e.ID)\n\t\t\t}\n\t\t}\n\n\t\tif err := datastore.DeleteWebhookEventsByID(ctx, tx, toBeDeleted); err != nil {\n\t\t\treturn db.WrapError(err)\n\t\t}\n\n\t\t// Prune events that are already in the list.\n\t\tnewEvents := make([]int, 0)\n\t\tfor _, e := range updatedEvents {\n\t\t\tfound := false\n\t\t\tfor _, ne := range currentEvents {\n\t\t\t\tif int(e) == ne.Event {\n\t\t\t\t\tfound = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif !found {\n\t\t\t\tnewEvents = append(newEvents, int(e))\n\t\t\t}\n\t\t}\n\n\t\tif err := datastore.CreateWebhookEvents(ctx, tx, id, newEvents); err != nil {\n\t\t\treturn db.WrapError(err)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\n// DeleteWebhook deletes a webhook for a repository.\nfunc (b *Backend) DeleteWebhook(ctx context.Context, repo proto.Repository, id int64) error {\n\tdbx := db.FromContext(ctx)\n\tdatastore := store.FromContext(ctx)\n\n\treturn dbx.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\t_, err := datastore.GetWebhookByID(ctx, tx, repo.ID(), id)\n\t\tif err != nil {\n\t\t\treturn db.WrapError(err)\n\t\t}\n\t\tif err := datastore.DeleteWebhookForRepoByID(ctx, tx, repo.ID(), id); err != nil {\n\t\t\treturn db.WrapError(err)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\n// ListWebhookDeliveries lists webhook deliveries for a webhook.\nfunc (b *Backend) ListWebhookDeliveries(ctx context.Context, id int64) ([]webhook.Delivery, error) {\n\tdbx := db.FromContext(ctx)\n\tdatastore := store.FromContext(ctx)\n\n\tvar deliveries []models.WebhookDelivery\n\tif err := dbx.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\tvar err error\n\t\tdeliveries, err = datastore.ListWebhookDeliveriesByWebhookID(ctx, tx, id)\n\t\tif err != nil {\n\t\t\treturn db.WrapError(err)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, db.WrapError(err)\n\t}\n\n\tds := make([]webhook.Delivery, len(deliveries))\n\tfor i, d := range deliveries {\n\t\tds[i] = webhook.Delivery{\n\t\t\tWebhookDelivery: d,\n\t\t\tEvent:           webhook.Event(d.Event),\n\t\t}\n\t}\n\n\treturn ds, nil\n}\n\n// RedeliverWebhookDelivery redelivers a webhook delivery.\nfunc (b *Backend) RedeliverWebhookDelivery(ctx context.Context, repo proto.Repository, id int64, delID uuid.UUID) error {\n\tdbx := db.FromContext(ctx)\n\tdatastore := store.FromContext(ctx)\n\n\tvar delivery models.WebhookDelivery\n\tvar wh models.Webhook\n\tif err := dbx.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\tvar err error\n\t\twh, err = datastore.GetWebhookByID(ctx, tx, repo.ID(), id)\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"error getting webhook: %v\", err)\n\t\t\treturn db.WrapError(err)\n\t\t}\n\n\t\tdelivery, err = datastore.GetWebhookDeliveryByID(ctx, tx, id, delID)\n\t\tif err != nil {\n\t\t\treturn db.WrapError(err)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn db.WrapError(err)\n\t}\n\n\tlog.Infof(\"redelivering webhook delivery %s for webhook %d\\n\\n%s\\n\\n\", delID, id, delivery.RequestBody)\n\n\tvar payload json.RawMessage\n\tif err := json.Unmarshal([]byte(delivery.RequestBody), &payload); err != nil {\n\t\tlog.Errorf(\"error unmarshaling webhook payload: %v\", err)\n\t\treturn err\n\t}\n\n\treturn webhook.SendWebhook(ctx, wh, webhook.Event(delivery.Event), payload)\n}\n\n// WebhookDelivery returns a webhook delivery.\nfunc (b *Backend) WebhookDelivery(ctx context.Context, webhookID int64, id uuid.UUID) (webhook.Delivery, error) {\n\tdbx := db.FromContext(ctx)\n\tdatastore := store.FromContext(ctx)\n\n\tvar delivery webhook.Delivery\n\tif err := dbx.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\td, err := datastore.GetWebhookDeliveryByID(ctx, tx, webhookID, id)\n\t\tif err != nil {\n\t\t\treturn db.WrapError(err)\n\t\t}\n\n\t\tdelivery = webhook.Delivery{\n\t\t\tWebhookDelivery: d,\n\t\t\tEvent:           webhook.Event(d.Event),\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn webhook.Delivery{}, db.WrapError(err)\n\t}\n\n\treturn delivery, nil\n}\n"
  },
  {
    "path": "pkg/config/config.go",
    "content": "package config\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/caarlos0/env/v11\"\n\t\"github.com/charmbracelet/soft-serve/pkg/sshutils\"\n\t\"golang.org/x/crypto/ssh\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nvar binPath = \"soft\"\n\n// SSHConfig is the configuration for the SSH server.\ntype SSHConfig struct {\n\t// Enabled toggles the SSH server on/off\n\tEnabled bool `env:\"ENABLED\" yaml:\"enabled\"`\n\n\t// ListenAddr is the address on which the SSH server will listen.\n\tListenAddr string `env:\"LISTEN_ADDR\" yaml:\"listen_addr\"`\n\n\t// PublicURL is the public URL of the SSH server.\n\tPublicURL string `env:\"PUBLIC_URL\" yaml:\"public_url\"`\n\n\t// KeyPath is the path to the SSH server's private key.\n\tKeyPath string `env:\"KEY_PATH\" yaml:\"key_path\"`\n\n\t// ClientKeyPath is the path to the server's client private key.\n\tClientKeyPath string `env:\"CLIENT_KEY_PATH\" yaml:\"client_key_path\"`\n\n\t// MaxTimeout is the maximum number of seconds a connection can take.\n\tMaxTimeout int `env:\"MAX_TIMEOUT\" yaml:\"max_timeout\"`\n\n\t// IdleTimeout is the number of seconds a connection can be idle before it is closed.\n\tIdleTimeout int `env:\"IDLE_TIMEOUT\" yaml:\"idle_timeout\"`\n}\n\n// GitConfig is the Git daemon configuration for the server.\ntype GitConfig struct {\n\t// Enabled toggles the Git daemon on/off\n\tEnabled bool `env:\"ENABLED\" yaml:\"enabled\"`\n\n\t// ListenAddr is the address on which the Git daemon will listen.\n\tListenAddr string `env:\"LISTEN_ADDR\" yaml:\"listen_addr\"`\n\n\t// PublicURL is the public URL of the Git daemon server.\n\tPublicURL string `env:\"PUBLIC_URL\" yaml:\"public_url\"`\n\n\t// MaxTimeout is the maximum number of seconds a connection can take.\n\tMaxTimeout int `env:\"MAX_TIMEOUT\" yaml:\"max_timeout\"`\n\n\t// IdleTimeout is the number of seconds a connection can be idle before it is closed.\n\tIdleTimeout int `env:\"IDLE_TIMEOUT\" yaml:\"idle_timeout\"`\n\n\t// MaxConnections is the maximum number of concurrent connections.\n\tMaxConnections int `env:\"MAX_CONNECTIONS\" yaml:\"max_connections\"`\n}\n\n// CORSConfig is the CORS configuration for the server.\ntype CORSConfig struct {\n\tAllowedHeaders []string `env:\"ALLOWED_HEADERS\" yaml:\"allowed_headers\"`\n\n\tAllowedOrigins []string `env:\"ALLOWED_ORIGINS\" yaml:\"allowed_origins\"`\n\n\tAllowedMethods []string `env:\"ALLOWED_METHODS\" yaml:\"allowed_methods\"`\n}\n\n// HTTPConfig is the HTTP configuration for the server.\ntype HTTPConfig struct {\n\t// Enabled toggles the HTTP server on/off\n\tEnabled bool `env:\"ENABLED\" yaml:\"enabled\"`\n\n\t// ListenAddr is the address on which the HTTP server will listen.\n\tListenAddr string `env:\"LISTEN_ADDR\" yaml:\"listen_addr\"`\n\n\t// TLSKeyPath is the path to the TLS private key.\n\tTLSKeyPath string `env:\"TLS_KEY_PATH\" yaml:\"tls_key_path\"`\n\n\t// TLSCertPath is the path to the TLS certificate.\n\tTLSCertPath string `env:\"TLS_CERT_PATH\" yaml:\"tls_cert_path\"`\n\n\t// PublicURL is the public URL of the HTTP server.\n\tPublicURL string `env:\"PUBLIC_URL\" yaml:\"public_url\"`\n\n\t// CORS is the cross-origin configuration for the HTTP server.\n\tCORS CORSConfig `envPrefix:\"CORS_\" yaml:\"cors\"`\n}\n\n// StatsConfig is the configuration for the stats server.\ntype StatsConfig struct {\n\t// Enabled toggles the Stats server on/off\n\tEnabled bool `env:\"ENABLED\" yaml:\"enabled\"`\n\n\t// ListenAddr is the address on which the stats server will listen.\n\tListenAddr string `env:\"LISTEN_ADDR\" yaml:\"listen_addr\"`\n}\n\n// LogConfig is the logger configuration.\ntype LogConfig struct {\n\t// Format is the format of the logs.\n\t// Valid values are \"json\", \"logfmt\", and \"text\".\n\tFormat string `env:\"FORMAT\" yaml:\"format\"`\n\n\t// Time format for the log `ts` field.\n\t// Format must be described in Golang's time format.\n\tTimeFormat string `env:\"TIME_FORMAT\" yaml:\"time_format\"`\n\n\t// Path to a file to write logs to.\n\t// If not set, logs will be written to stderr.\n\tPath string `env:\"PATH\" yaml:\"path\"`\n}\n\n// DBConfig is the database connection configuration.\ntype DBConfig struct {\n\t// Driver is the driver for the database.\n\tDriver string `env:\"DRIVER\" yaml:\"driver\"`\n\n\t// DataSource is the database data source name.\n\tDataSource string `env:\"DATA_SOURCE\" yaml:\"data_source\"`\n}\n\n// LFSConfig is the configuration for Git LFS.\ntype LFSConfig struct {\n\t// Enabled is whether or not Git LFS is enabled.\n\tEnabled bool `env:\"ENABLED\" yaml:\"enabled\"`\n\n\t// SSHEnabled is whether or not Git LFS over SSH is enabled.\n\t// This is only used if LFS is enabled.\n\tSSHEnabled bool `env:\"SSH_ENABLED\" yaml:\"ssh_enabled\"`\n}\n\n// JobsConfig is the configuration for cron jobs.\ntype JobsConfig struct {\n\tMirrorPull string `env:\"MIRROR_PULL\" yaml:\"mirror_pull\"`\n}\n\n// Config is the configuration for Soft Serve.\ntype Config struct {\n\t// Name is the name of the server.\n\tName string `env:\"NAME\" yaml:\"name\"`\n\n\t// SSH is the configuration for the SSH server.\n\tSSH SSHConfig `envPrefix:\"SSH_\" yaml:\"ssh\"`\n\n\t// Git is the configuration for the Git daemon.\n\tGit GitConfig `envPrefix:\"GIT_\" yaml:\"git\"`\n\n\t// HTTP is the configuration for the HTTP server.\n\tHTTP HTTPConfig `envPrefix:\"HTTP_\" yaml:\"http\"`\n\n\t// Stats is the configuration for the stats server.\n\tStats StatsConfig `envPrefix:\"STATS_\" yaml:\"stats\"`\n\n\t// Log is the logger configuration.\n\tLog LogConfig `envPrefix:\"LOG_\" yaml:\"log\"`\n\n\t// DB is the database configuration.\n\tDB DBConfig `envPrefix:\"DB_\" yaml:\"db\"`\n\n\t// LFS is the configuration for Git LFS.\n\tLFS LFSConfig `envPrefix:\"LFS_\" yaml:\"lfs\"`\n\n\t// Jobs is the configuration for cron jobs\n\tJobs JobsConfig `envPrefix:\"JOBS_\" yaml:\"jobs\"`\n\n\t// InitialAdminKeys is a list of public keys that will be added to the list of admins.\n\tInitialAdminKeys []string `env:\"INITIAL_ADMIN_KEYS\" envSeparator:\"\\n\" yaml:\"initial_admin_keys\"`\n\n\t// DataPath is the path to the directory where Soft Serve will store its data.\n\tDataPath string `env:\"DATA_PATH\" yaml:\"-\"`\n}\n\n// Environ returns the config as a list of environment variables.\nfunc (c *Config) Environ() []string {\n\tenvs := []string{\n\t\tfmt.Sprintf(\"SOFT_SERVE_BIN_PATH=%s\", binPath),\n\t}\n\tif c == nil {\n\t\treturn envs\n\t}\n\n\t// TODO: do this dynamically\n\tenvs = append(envs, []string{\n\t\tfmt.Sprintf(\"SOFT_SERVE_CONFIG_LOCATION=%s\", c.ConfigPath()),\n\t\tfmt.Sprintf(\"SOFT_SERVE_DATA_PATH=%s\", c.DataPath),\n\t\tfmt.Sprintf(\"SOFT_SERVE_NAME=%s\", c.Name),\n\t\tfmt.Sprintf(\"SOFT_SERVE_INITIAL_ADMIN_KEYS=%s\", strings.Join(c.InitialAdminKeys, \"\\n\")),\n\t\tfmt.Sprintf(\"SOFT_SERVE_SSH_ENABLED=%t\", c.SSH.Enabled),\n\t\tfmt.Sprintf(\"SOFT_SERVE_SSH_LISTEN_ADDR=%s\", c.SSH.ListenAddr),\n\t\tfmt.Sprintf(\"SOFT_SERVE_SSH_PUBLIC_URL=%s\", c.SSH.PublicURL),\n\t\tfmt.Sprintf(\"SOFT_SERVE_SSH_KEY_PATH=%s\", c.SSH.KeyPath),\n\t\tfmt.Sprintf(\"SOFT_SERVE_SSH_CLIENT_KEY_PATH=%s\", c.SSH.ClientKeyPath),\n\t\tfmt.Sprintf(\"SOFT_SERVE_SSH_MAX_TIMEOUT=%d\", c.SSH.MaxTimeout),\n\t\tfmt.Sprintf(\"SOFT_SERVE_SSH_IDLE_TIMEOUT=%d\", c.SSH.IdleTimeout),\n\t\tfmt.Sprintf(\"SOFT_SERVE_GIT_ENABLED=%t\", c.Git.Enabled),\n\t\tfmt.Sprintf(\"SOFT_SERVE_GIT_LISTEN_ADDR=%s\", c.Git.ListenAddr),\n\t\tfmt.Sprintf(\"SOFT_SERVE_GIT_PUBLIC_URL=%s\", c.Git.PublicURL),\n\t\tfmt.Sprintf(\"SOFT_SERVE_GIT_MAX_TIMEOUT=%d\", c.Git.MaxTimeout),\n\t\tfmt.Sprintf(\"SOFT_SERVE_GIT_IDLE_TIMEOUT=%d\", c.Git.IdleTimeout),\n\t\tfmt.Sprintf(\"SOFT_SERVE_GIT_MAX_CONNECTIONS=%d\", c.Git.MaxConnections),\n\t\tfmt.Sprintf(\"SOFT_SERVE_HTTP_ENABLED=%t\", c.HTTP.Enabled),\n\t\tfmt.Sprintf(\"SOFT_SERVE_HTTP_LISTEN_ADDR=%s\", c.HTTP.ListenAddr),\n\t\tfmt.Sprintf(\"SOFT_SERVE_HTTP_TLS_KEY_PATH=%s\", c.HTTP.TLSKeyPath),\n\t\tfmt.Sprintf(\"SOFT_SERVE_HTTP_TLS_CERT_PATH=%s\", c.HTTP.TLSCertPath),\n\t\tfmt.Sprintf(\"SOFT_SERVE_HTTP_PUBLIC_URL=%s\", c.HTTP.PublicURL),\n\t\tfmt.Sprintf(\"SOFT_SERVE_HTTP_CORS_ALLOWED_HEADERS=%s\", strings.Join(c.HTTP.CORS.AllowedHeaders, \",\")),\n\t\tfmt.Sprintf(\"SOFT_SERVE_HTTP_CORS_ALLOWED_ORIGINS=%s\", strings.Join(c.HTTP.CORS.AllowedOrigins, \",\")),\n\t\tfmt.Sprintf(\"SOFT_SERVE_HTTP_CORS_ALLOWED_METHODS=%s\", strings.Join(c.HTTP.CORS.AllowedMethods, \",\")),\n\t\tfmt.Sprintf(\"SOFT_SERVE_STATS_ENABLED=%t\", c.Stats.Enabled),\n\t\tfmt.Sprintf(\"SOFT_SERVE_STATS_LISTEN_ADDR=%s\", c.Stats.ListenAddr),\n\t\tfmt.Sprintf(\"SOFT_SERVE_LOG_FORMAT=%s\", c.Log.Format),\n\t\tfmt.Sprintf(\"SOFT_SERVE_LOG_TIME_FORMAT=%s\", c.Log.TimeFormat),\n\t\tfmt.Sprintf(\"SOFT_SERVE_DB_DRIVER=%s\", c.DB.Driver),\n\t\tfmt.Sprintf(\"SOFT_SERVE_DB_DATA_SOURCE=%s\", c.DB.DataSource),\n\t\tfmt.Sprintf(\"SOFT_SERVE_LFS_ENABLED=%t\", c.LFS.Enabled),\n\t\tfmt.Sprintf(\"SOFT_SERVE_LFS_SSH_ENABLED=%t\", c.LFS.SSHEnabled),\n\t\tfmt.Sprintf(\"SOFT_SERVE_JOBS_MIRROR_PULL=%s\", c.Jobs.MirrorPull),\n\t}...)\n\n\treturn envs\n}\n\n// IsDebug returns true if the server is running in debug mode.\nfunc IsDebug() bool {\n\tdebug, _ := strconv.ParseBool(os.Getenv(\"SOFT_SERVE_DEBUG\"))\n\treturn debug\n}\n\n// IsVerbose returns true if the server is running in verbose mode.\n// Verbose mode is only enabled if debug mode is enabled.\nfunc IsVerbose() bool {\n\tverbose, _ := strconv.ParseBool(os.Getenv(\"SOFT_SERVE_VERBOSE\"))\n\treturn IsDebug() && verbose\n}\n\n// parseFile parses the given file as a configuration file.\n// The file must be in YAML format.\nfunc parseFile(cfg *Config, path string) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer f.Close() //nolint: errcheck\n\tif err := yaml.NewDecoder(f).Decode(cfg); err != nil {\n\t\treturn fmt.Errorf(\"decode config: %w\", err)\n\t}\n\n\treturn cfg.Validate()\n}\n\n// ParseFile parses the config from the default file path.\n// This also calls Validate() on the config.\nfunc (c *Config) ParseFile() error {\n\treturn parseFile(c, c.ConfigPath())\n}\n\n// parseEnv parses the environment variables as a configuration file.\nfunc parseEnv(cfg *Config) error {\n\t// Merge initial admin keys from both config file and environment variables.\n\tinitialAdminKeys := append([]string{}, cfg.InitialAdminKeys...)\n\n\t// Override with environment variables\n\tif err := env.ParseWithOptions(cfg, env.Options{\n\t\tPrefix: \"SOFT_SERVE_\",\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"parse environment variables: %w\", err)\n\t}\n\n\t// Merge initial admin keys from environment variables.\n\tif initialAdminKeysEnv := os.Getenv(\"SOFT_SERVE_INITIAL_ADMIN_KEYS\"); initialAdminKeysEnv != \"\" {\n\t\tcfg.InitialAdminKeys = append(cfg.InitialAdminKeys, initialAdminKeys...)\n\t}\n\n\treturn cfg.Validate()\n}\n\n// ParseEnv parses the config from the environment variables.\n// This also calls Validate() on the config.\nfunc (c *Config) ParseEnv() error {\n\treturn parseEnv(c)\n}\n\n// Parse parses the config from the default file path and environment variables.\n// This also calls Validate() on the config.\nfunc (c *Config) Parse() error {\n\tif err := c.ParseFile(); err != nil {\n\t\treturn err\n\t}\n\n\treturn c.ParseEnv()\n}\n\n// writeConfig writes the configuration to the given file.\nfunc writeConfig(cfg *Config, path string) error {\n\tif err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {\n\t\treturn err\n\t}\n\treturn os.WriteFile(path, []byte(newConfigFile(cfg)), 0o644) //nolint: errcheck, gosec\n}\n\n// WriteConfig writes the configuration to the default file.\nfunc (c *Config) WriteConfig() error {\n\treturn writeConfig(c, c.ConfigPath())\n}\n\n// DefaultDataPath returns the path to the data directory.\n// It uses the SOFT_SERVE_DATA_PATH environment variable if set, otherwise it\n// uses \"data\".\nfunc DefaultDataPath() string {\n\tdp := os.Getenv(\"SOFT_SERVE_DATA_PATH\")\n\tif dp == \"\" {\n\t\tdp = \"data\"\n\t}\n\n\treturn dp\n}\n\n// ConfigPath returns the path to the config file.\nfunc (c *Config) ConfigPath() string { //nolint:revive\n\t// If we have a custom config location set, then use that.\n\tif path := os.Getenv(\"SOFT_SERVE_CONFIG_LOCATION\"); exist(path) {\n\t\treturn path\n\t}\n\n\t// Otherwise, look in the data path.\n\treturn filepath.Join(c.DataPath, \"config.yaml\")\n}\n\nfunc exist(path string) bool {\n\t_, err := os.Stat(path)\n\treturn err == nil\n}\n\n// Exist returns true if the config file exists.\nfunc (c *Config) Exist() bool {\n\treturn exist(c.ConfigPath())\n}\n\n// DefaultConfig returns the default Config. All the path values are relative\n// to the data directory.\n// Use Validate() to validate the config and ensure absolute paths.\nfunc DefaultConfig() *Config {\n\treturn &Config{\n\t\tName:     \"Soft Serve\",\n\t\tDataPath: DefaultDataPath(),\n\t\tSSH: SSHConfig{\n\t\t\tEnabled:       true,\n\t\t\tListenAddr:    \":23231\",\n\t\t\tPublicURL:     \"ssh://localhost:23231\",\n\t\t\tKeyPath:       filepath.Join(\"ssh\", \"soft_serve_host_ed25519\"),\n\t\t\tClientKeyPath: filepath.Join(\"ssh\", \"soft_serve_client_ed25519\"),\n\t\t\tMaxTimeout:    0,\n\t\t\tIdleTimeout:   10 * 60, // 10 minutes\n\t\t},\n\t\tGit: GitConfig{\n\t\t\tEnabled:        true,\n\t\t\tListenAddr:     \":9418\",\n\t\t\tPublicURL:      \"git://localhost\",\n\t\t\tMaxTimeout:     0,\n\t\t\tIdleTimeout:    3,\n\t\t\tMaxConnections: 32,\n\t\t},\n\t\tHTTP: HTTPConfig{\n\t\t\tEnabled:    true,\n\t\t\tListenAddr: \":23232\",\n\t\t\tPublicURL:  \"http://localhost:23232\",\n\t\t\tCORS: CORSConfig{\n\t\t\t\tAllowedHeaders: []string{\"Accept\", \"Accept-Language\", \"Content-Language\", \"Content-Type\", \"Origin\", \"X-Requested-With\", \"User-Agent\", \"Authorization\", \"Access-Control-Request-Method\", \"Access-Control-Allow-Origin\"},\n\t\t\t\tAllowedMethods: []string{\"GET\", \"HEAD\", \"POST\", \"PUT\", \"OPTIONS\"},\n\t\t\t\tAllowedOrigins: []string{\"http://localhost:23232\"},\n\t\t\t},\n\t\t},\n\t\tStats: StatsConfig{\n\t\t\tEnabled:    true,\n\t\t\tListenAddr: \"localhost:23233\",\n\t\t},\n\t\tLog: LogConfig{\n\t\t\tFormat:     \"text\",\n\t\t\tTimeFormat: time.DateTime,\n\t\t},\n\t\tDB: DBConfig{\n\t\t\tDriver: \"sqlite\",\n\t\t\tDataSource: \"soft-serve.db\" +\n\t\t\t\t\"?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)\",\n\t\t},\n\t\tLFS: LFSConfig{\n\t\t\tEnabled:    true,\n\t\t\tSSHEnabled: false,\n\t\t},\n\t\tJobs: JobsConfig{\n\t\t\tMirrorPull: \"@every 10m\",\n\t\t},\n\t}\n}\n\n// Validate validates the configuration.\n// It updates the configuration with absolute paths.\nfunc (c *Config) Validate() error {\n\t// Use absolute paths\n\tif !filepath.IsAbs(c.DataPath) {\n\t\tdp, err := filepath.Abs(c.DataPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tc.DataPath = dp\n\t}\n\n\tc.SSH.PublicURL = strings.TrimSuffix(c.SSH.PublicURL, \"/\")\n\tc.HTTP.PublicURL = strings.TrimSuffix(c.HTTP.PublicURL, \"/\")\n\n\tif c.SSH.KeyPath != \"\" && !filepath.IsAbs(c.SSH.KeyPath) {\n\t\tc.SSH.KeyPath = filepath.Join(c.DataPath, c.SSH.KeyPath)\n\t}\n\n\tif c.SSH.ClientKeyPath != \"\" && !filepath.IsAbs(c.SSH.ClientKeyPath) {\n\t\tc.SSH.ClientKeyPath = filepath.Join(c.DataPath, c.SSH.ClientKeyPath)\n\t}\n\n\tif c.HTTP.TLSKeyPath != \"\" && !filepath.IsAbs(c.HTTP.TLSKeyPath) {\n\t\tc.HTTP.TLSKeyPath = filepath.Join(c.DataPath, c.HTTP.TLSKeyPath)\n\t}\n\n\tif c.HTTP.TLSCertPath != \"\" && !filepath.IsAbs(c.HTTP.TLSCertPath) {\n\t\tc.HTTP.TLSCertPath = filepath.Join(c.DataPath, c.HTTP.TLSCertPath)\n\t}\n\n\tif strings.HasPrefix(c.DB.Driver, \"sqlite\") && !filepath.IsAbs(c.DB.DataSource) {\n\t\tc.DB.DataSource = filepath.Join(c.DataPath, c.DB.DataSource)\n\t}\n\n\t// Validate keys\n\tpks := make([]string, 0)\n\tfor _, key := range parseAuthKeys(c.InitialAdminKeys) {\n\t\tak := sshutils.MarshalAuthorizedKey(key)\n\t\tpks = append(pks, ak)\n\t}\n\n\tc.InitialAdminKeys = pks\n\n\tc.HTTP.CORS.AllowedOrigins = append([]string{c.HTTP.PublicURL}, c.HTTP.CORS.AllowedOrigins...)\n\n\treturn nil\n}\n\n// parseAuthKeys parses authorized keys from either file paths or string authorized_keys.\nfunc parseAuthKeys(aks []string) []ssh.PublicKey {\n\texist := make(map[string]struct{}, 0)\n\tpks := make([]ssh.PublicKey, 0)\n\tfor _, key := range aks {\n\t\tif bts, err := os.ReadFile(key); err == nil {\n\t\t\t// key is a file\n\t\t\tkey = strings.TrimSpace(string(bts))\n\t\t}\n\n\t\tif pk, _, err := sshutils.ParseAuthorizedKey(key); err == nil {\n\t\t\tif _, ok := exist[key]; !ok {\n\t\t\t\tpks = append(pks, pk)\n\t\t\t\texist[key] = struct{}{}\n\t\t\t}\n\t\t}\n\t}\n\treturn pks\n}\n\n// AdminKeys returns the server admin keys.\nfunc (c *Config) AdminKeys() []ssh.PublicKey {\n\treturn parseAuthKeys(c.InitialAdminKeys)\n}\n\nfunc init() {\n\tif ex, err := os.Executable(); err == nil {\n\t\tbinPath = filepath.ToSlash(ex)\n\t}\n}\n"
  },
  {
    "path": "pkg/config/config_test.go",
    "content": "package config\n\nimport (\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/matryer/is\"\n)\n\nfunc TestParseMultipleKeys(t *testing.T) {\n\tis := is.New(t)\n\ttd := t.TempDir()\n\tis.NoErr(os.Setenv(\"SOFT_SERVE_INITIAL_ADMIN_KEYS\", \"testdata/k1.pub\\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b\"))\n\tis.NoErr(os.Setenv(\"SOFT_SERVE_DATA_PATH\", td))\n\tt.Cleanup(func() {\n\t\tis.NoErr(os.Unsetenv(\"SOFT_SERVE_INITIAL_ADMIN_KEYS\"))\n\t\tis.NoErr(os.Unsetenv(\"SOFT_SERVE_DATA_PATH\"))\n\t})\n\tcfg := DefaultConfig()\n\tis.NoErr(cfg.ParseEnv())\n\tis.Equal(cfg.InitialAdminKeys, []string{\n\t\t\"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH\",\n\t\t\"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8\",\n\t})\n}\n\nfunc TestMergeInitAdminKeys(t *testing.T) {\n\tis := is.New(t)\n\tis.NoErr(os.Setenv(\"SOFT_SERVE_INITIAL_ADMIN_KEYS\", \"testdata/k1.pub\"))\n\tt.Cleanup(func() { is.NoErr(os.Unsetenv(\"SOFT_SERVE_INITIAL_ADMIN_KEYS\")) })\n\tcfg := &Config{\n\t\tDataPath:         t.TempDir(),\n\t\tInitialAdminKeys: []string{\"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b\"},\n\t}\n\tis.NoErr(cfg.WriteConfig())\n\tis.NoErr(cfg.Parse())\n\tis.Equal(cfg.InitialAdminKeys, []string{\n\t\t\"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH\",\n\t\t\"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8\",\n\t})\n}\n\nfunc TestValidateInitAdminKeys(t *testing.T) {\n\tis := is.New(t)\n\tcfg := &Config{\n\t\tDataPath: t.TempDir(),\n\t\tInitialAdminKeys: []string{\n\t\t\t\"testdata/k1.pub\",\n\t\t\t\"abc\",\n\t\t\t\"\",\n\t\t},\n\t}\n\tis.NoErr(cfg.WriteConfig())\n\tis.NoErr(cfg.Parse())\n\tis.Equal(cfg.InitialAdminKeys, []string{\n\t\t\"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH\",\n\t})\n}\n\nfunc TestCustomConfigLocation(t *testing.T) {\n\tis := is.New(t)\n\ttd := t.TempDir()\n\tt.Cleanup(func() {\n\t\tis.NoErr(os.Unsetenv(\"SOFT_SERVE_CONFIG_LOCATION\"))\n\t})\n\n\t// Test that we get data from the custom file location, and not from the data dir.\n\tis.NoErr(os.Setenv(\"SOFT_SERVE_CONFIG_LOCATION\", \"testdata/config.yaml\"))\n\tis.NoErr(os.Setenv(\"SOFT_SERVE_DATA_PATH\", td))\n\tcfg := DefaultConfig()\n\tis.NoErr(cfg.Parse())\n\tis.Equal(cfg.Name, \"Test server name\")\n\t// If we unset the custom location, then use the default location.\n\tis.NoErr(os.Unsetenv(\"SOFT_SERVE_CONFIG_LOCATION\"))\n\tcfg = DefaultConfig()\n\tis.Equal(cfg.Name, \"Soft Serve\")\n\t// Test that if the custom config location doesn't exist, default to datapath config.\n\tis.NoErr(os.Setenv(\"SOFT_SERVE_CONFIG_LOCATION\", \"testdata/config_nonexistent.yaml\"))\n\tcfg = DefaultConfig()\n\tis.Equal(cfg.Name, \"Soft Serve\")\n}\n\nfunc TestParseMultipleHeaders(t *testing.T) {\n\tis := is.New(t)\n\tis.NoErr(os.Setenv(\"SOFT_SERVE_HTTP_CORS_ALLOWED_HEADERS\", \"Accept,Accept-Language,User-Agent\"))\n\tt.Cleanup(func() {\n\t\tis.NoErr(os.Unsetenv(\"SOFT_SERVE_HTTP_CORS_ALLOWED_HEADERS\"))\n\t})\n\tcfg := DefaultConfig()\n\tis.NoErr(cfg.ParseEnv())\n\tis.Equal(cfg.HTTP.CORS.AllowedHeaders, []string{\n\t\t\"Accept\",\n\t\t\"Accept-Language\",\n\t\t\"User-Agent\",\n\t})\n}\n\nfunc TestParseMultipleOrigins(t *testing.T) {\n\tis := is.New(t)\n\tis.NoErr(os.Setenv(\"SOFT_SERVE_HTTP_CORS_ALLOWED_ORIGINS\", \"http://example.com,https://example.com\"))\n\tt.Cleanup(func() {\n\t\tis.NoErr(os.Unsetenv(\"SOFT_SERVE_HTTP_CORS_ALLOWED_ORIGINS\"))\n\t})\n\tcfg := DefaultConfig()\n\tis.NoErr(cfg.ParseEnv())\n\tis.Equal(cfg.HTTP.CORS.AllowedOrigins, []string{\n\t\t\"http://localhost:23232\",\n\t\t\"http://example.com\",\n\t\t\"https://example.com\",\n\t})\n}\n\nfunc TestParseMultipleMethods(t *testing.T) {\n\tis := is.New(t)\n\tis.NoErr(os.Setenv(\"SOFT_SERVE_HTTP_CORS_ALLOWED_METHODS\", \"GET,POST,PUT\"))\n\tt.Cleanup(func() {\n\t\tis.NoErr(os.Unsetenv(\"SOFT_SERVE_HTTP_CORS_ALLOWED_METHODS\"))\n\t})\n\tcfg := DefaultConfig()\n\tis.NoErr(cfg.ParseEnv())\n\tis.Equal(cfg.HTTP.CORS.AllowedMethods, []string{\n\t\t\"GET\",\n\t\t\"POST\",\n\t\t\"PUT\",\n\t})\n}\n"
  },
  {
    "path": "pkg/config/context.go",
    "content": "package config\n\nimport \"context\"\n\n// ContextKey is the context key for the config.\nvar ContextKey = struct{ string }{\"config\"}\n\n// WithContext returns a new context with the configuration attached.\nfunc WithContext(ctx context.Context, cfg *Config) context.Context {\n\treturn context.WithValue(ctx, ContextKey, cfg)\n}\n\n// FromContext returns the configuration from the context.\nfunc FromContext(ctx context.Context) *Config {\n\tif c, ok := ctx.Value(ContextKey).(*Config); ok {\n\t\treturn c\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/config/context_test.go",
    "content": "package config\n\nimport (\n\t\"context\"\n\t\"reflect\"\n\t\"testing\"\n)\n\nfunc TestBadFromContext(t *testing.T) {\n\tctx := context.TODO()\n\tif c := FromContext(ctx); c != nil {\n\t\tt.Errorf(\"FromContext(ctx) => %v, want %v\", c, nil)\n\t}\n}\n\nfunc TestGoodFromContext(t *testing.T) {\n\tctx := WithContext(context.TODO(), &Config{})\n\tif c := FromContext(ctx); c == nil {\n\t\tt.Errorf(\"FromContext(ctx) => %v, want %v\", c, &Config{})\n\t}\n}\n\nfunc TestGoodFromContextWithDefaultConfig(t *testing.T) {\n\tcfg := DefaultConfig()\n\tctx := WithContext(context.TODO(), cfg)\n\tif c := FromContext(ctx); c == nil || !reflect.DeepEqual(c, cfg) {\n\t\tt.Errorf(\"FromContext(ctx) => %v, want %v\", c, cfg)\n\t}\n}\n"
  },
  {
    "path": "pkg/config/file.go",
    "content": "package config\n\nimport (\n\t\"bytes\"\n\t\"text/template\"\n)\n\nvar configFileTmpl = template.Must(template.New(\"config\").Parse(`# Soft Serve Server configurations\n\n# The name of the server.\n# This is the name that will be displayed in the UI.\nname: \"{{ .Name }}\"\n\n# Logging configuration.\nlog:\n  # Log format to use. Valid values are \"json\", \"logfmt\", and \"text\".\n  format: \"{{ .Log.Format }}\"\n  # Time format for the log \"timestamp\" field.\n  # Should be described in Golang's time format.\n  time_format: \"{{ .Log.TimeFormat }}\"\n  # Path to the log file. Leave empty to write to stderr.\n  #path: \"{{ .Log.Path }}\"\n\n# The SSH server configuration.\nssh:\n  # Enable SSH.\n  enabled: {{ .SSH.Enabled }}\n\n  # The address on which the SSH server will listen.\n  listen_addr: \"{{ .SSH.ListenAddr }}\"\n\n  # The public URL of the SSH server.\n  # This is the address that will be used to clone repositories.\n  public_url: \"{{ .SSH.PublicURL }}\"\n\n  # The path to the SSH server's private key.\n  key_path: {{ .SSH.KeyPath }}\n\n  # The path to the server's client private key. This key will be used to\n  # authenticate the server to make git requests to ssh remotes.\n  client_key_path: {{ .SSH.ClientKeyPath }}\n\n  # The maximum number of seconds a connection can take.\n  # A value of 0 means no timeout.\n  max_timeout: {{ .SSH.MaxTimeout }}\n\n  # The number of seconds a connection can be idle before it is closed.\n  # A value of 0 means no timeout.\n  idle_timeout: {{ .SSH.IdleTimeout }}\n\n# The Git daemon configuration.\ngit:\n  # Enable the Git daemon.\n  enabled: {{ .Git.Enabled }}\n\n  # The address on which the Git daemon will listen.\n  listen_addr: \"{{ .Git.ListenAddr }}\"\n\n  # The public URL of the Git daemon server.\n  # This is the address that will be used to clone repositories.\n  public_url: \"{{ .Git.PublicURL }}\"\n\n  # The maximum number of seconds a connection can take.\n  # A value of 0 means no timeout.\n  max_timeout: {{ .Git.MaxTimeout }}\n\n  # The number of seconds a connection can be idle before it is closed.\n  idle_timeout: {{ .Git.IdleTimeout }}\n\n  # The maximum number of concurrent connections.\n  max_connections: {{ .Git.MaxConnections }}\n\n# The HTTP server configuration.\nhttp:\n  # Enable the HTTP server.\n  enabled: {{ .HTTP.Enabled }}\n\n  # The address on which the HTTP server will listen.\n  listen_addr: \"{{ .HTTP.ListenAddr }}\"\n\n  # The path to the TLS private key.\n  tls_key_path: {{ .HTTP.TLSKeyPath }}\n\n  # The path to the TLS certificate.\n  tls_cert_path: {{ .HTTP.TLSCertPath }}\n\n  # The public URL of the HTTP server.\n  # This is the address that will be used to clone repositories.\n  # Make sure to use https:// if you are using TLS.\n  public_url: \"{{ .HTTP.PublicURL }}\"\n\n  # The cross-origin request security options\n  cors:\n    # The allowed cross-origin headers\n    allowed_headers:\n       - \"Accept\"\n       - \"Accept-Language\"\n       - \"Content-Language\"\n       - \"Content-Type\"\n       - \"Origin\"\n       - \"X-Requested-With\"\n       - \"User-Agent\"\n       - \"Authorization\"\n       - \"Access-Control-Request-Method\"\n       - \"Access-Control-Allow-Origin\"\n    # The allowed cross-origin URLs\n    allowed_origins:\n       - \"{{ .HTTP.PublicURL }}\" # always allowed\n       # - \"https://example.com\"\n    # The allowed cross-origin methods\n    allowed_methods:\n       - \"GET\"\n       - \"HEAD\"\n       - \"POST\"\n       - \"PUT\"\n       - \"OPTIONS\"\n\n# The stats server configuration.\nstats:\n  # Enable the stats server.\n  enabled: {{ .Stats.Enabled }}\n\n  # The address on which the stats server will listen.\n  listen_addr: \"{{ .Stats.ListenAddr }}\"\n\n# The database configuration.\ndb:\n  # The database driver to use.\n  # Valid values are \"sqlite\" and \"postgres\".\n  driver: \"{{ .DB.Driver }}\"\n  # The database data source name.\n  # This is driver specific and can be a file path or connection string.\n  # Make sure foreign key support is enabled when using SQLite.\n  data_source: \"{{ .DB.DataSource }}\"\n\n# Git LFS configuration.\nlfs:\n  # Enable Git LFS.\n  enabled: {{ .LFS.Enabled }}\n  # Enable Git SSH transfer.\n  ssh_enabled: {{ .LFS.SSHEnabled }}\n\n# Cron job configuration\njobs:\n  mirror_pull: \"{{ .Jobs.MirrorPull }}\"\n\n# Additional admin keys.\n#initial_admin_keys:\n#  - \"ssh-rsa AAAAB3NzaC1yc2...\"\n`))\n\nfunc newConfigFile(cfg *Config) string {\n\tvar b bytes.Buffer\n\tconfigFileTmpl.Execute(&b, cfg) //nolint: errcheck\n\treturn b.String()\n}\n"
  },
  {
    "path": "pkg/config/file_test.go",
    "content": "package config\n\nimport \"testing\"\n\nfunc TestNewConfigFile(t *testing.T) {\n\tfor _, cfg := range []*Config{\n\t\tnil,\n\t\tDefaultConfig(),\n\t\t{},\n\t} {\n\t\tif s := newConfigFile(cfg); s == \"\" {\n\t\t\tt.Errorf(\"newConfigFile(nil) => %q, want non-empty string\", s)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/config/ssh.go",
    "content": "package config\n\nimport (\n\t\"errors\"\n\n\t\"github.com/charmbracelet/keygen\"\n)\n\nvar (\n\t// ErrNilConfig is returned when a nil config is passed to a function.\n\tErrNilConfig = errors.New(\"nil config\")\n\n\t// ErrEmptySSHKeyPath is returned when the SSH key path is empty.\n\tErrEmptySSHKeyPath = errors.New(\"empty SSH key path\")\n)\n\n// KeyPair returns the server's SSH key pair.\nfunc KeyPair(cfg *Config) (*keygen.SSHKeyPair, error) {\n\tif cfg == nil {\n\t\treturn nil, ErrNilConfig\n\t}\n\n\tif cfg.SSH.KeyPath == \"\" {\n\t\treturn nil, ErrEmptySSHKeyPath\n\t}\n\n\treturn keygen.New(cfg.SSH.KeyPath, keygen.WithKeyType(keygen.Ed25519))\n}\n"
  },
  {
    "path": "pkg/config/ssh_test.go",
    "content": "package config\n\nimport \"testing\"\n\nfunc TestBadSSHKeyPair(t *testing.T) {\n\tfor _, cfg := range []*Config{\n\t\tnil,\n\t\t{},\n\t} {\n\t\tif _, err := KeyPair(cfg); err == nil {\n\t\t\tt.Errorf(\"cfg.SSH.KeyPair() => _, nil, want non-nil error\")\n\t\t}\n\t}\n}\n\nfunc TestGoodSSHKeyPair(t *testing.T) {\n\tcfg := &Config{\n\t\tSSH: SSHConfig{\n\t\t\tKeyPath: \"testdata/ssh_host_ed25519_key\",\n\t\t},\n\t}\n\n\tif _, err := KeyPair(cfg); err != nil {\n\t\tt.Errorf(\"cfg.SSH.KeyPair() => _, %v, want nil error\", err)\n\t}\n}\n"
  },
  {
    "path": "pkg/config/testdata/config.yaml",
    "content": "# Soft Serve Server configurations\n\nname: \"Test server name\"\n"
  },
  {
    "path": "pkg/config/testdata/k1.pub",
    "content": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b\n"
  },
  {
    "path": "pkg/cron/cron.go",
    "content": "package cron\n\nimport (\n\t\"context\"\n\t\"time\"\n\n\t\"charm.land/log/v2\"\n\t\"github.com/robfig/cron/v3\"\n)\n\n// Scheduler is a cron-like job scheduler.\ntype Scheduler struct {\n\t*cron.Cron\n}\n\n// cronLogger is a wrapper around the logger to make it compatible with the\n// cron logger.\ntype cronLogger struct {\n\tlogger *log.Logger\n}\n\n// Info logs routine messages about cron's operation.\nfunc (l cronLogger) Info(msg string, keysAndValues ...interface{}) {\n\tl.logger.Debug(msg, keysAndValues...)\n}\n\n// Error logs an error condition.\nfunc (l cronLogger) Error(err error, msg string, keysAndValues ...interface{}) {\n\tl.logger.Error(msg, append(keysAndValues, \"err\", err)...)\n}\n\n// NewScheduler returns a new Cron.\nfunc NewScheduler(ctx context.Context) *Scheduler {\n\tlogger := cronLogger{log.FromContext(ctx).WithPrefix(\"cron\")}\n\treturn &Scheduler{\n\t\tCron: cron.New(cron.WithLogger(logger)),\n\t}\n}\n\n// Shutdonw gracefully shuts down the Scheduler.\nfunc (s *Scheduler) Shutdown() {\n\tctx, cancel := context.WithTimeout(s.Cron.Stop(), 30*time.Second)\n\tdefer func() { cancel() }()\n\t<-ctx.Done()\n}\n\n// Start starts the Scheduler.\nfunc (s *Scheduler) Start() {\n\ts.Cron.Start()\n}\n\n// AddFunc adds a job to the Scheduler.\nfunc (s *Scheduler) AddFunc(spec string, fn func()) (int, error) {\n\tid, err := s.Cron.AddFunc(spec, fn)\n\treturn int(id), err\n}\n\n// Remove removes a job from the Scheduler.\nfunc (s *Scheduler) Remove(id int) {\n\ts.Cron.Remove(cron.EntryID(id))\n}\n"
  },
  {
    "path": "pkg/cron/cron_test.go",
    "content": "package cron\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"testing\"\n\n\t\"charm.land/log/v2\"\n)\n\nfunc TestCronLogger(t *testing.T) {\n\tvar buf bytes.Buffer\n\tlogger := log.New(&buf)\n\tlogger.SetLevel(log.DebugLevel)\n\tclogger := cronLogger{logger}\n\tclogger.Info(\"foo\")\n\tclogger.Error(fmt.Errorf(\"bar\"), \"test\")\n\tif buf.String() != \"DEBU foo\\nERRO test err=bar\\n\" {\n\t\tt.Errorf(\"unexpected log output: %s\", buf.String())\n\t}\n}\n\nfunc TestSchedularAddRemove(t *testing.T) {\n\ts := NewScheduler(context.TODO())\n\tid, err := s.AddFunc(\"* * * * *\", func() {})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\ts.Remove(id)\n}\n"
  },
  {
    "path": "pkg/daemon/conn.go",
    "content": "package daemon\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net\"\n\t\"sync\"\n\t\"time\"\n)\n\n// connections is a synchronizes access to to a net.Conn pool.\ntype connections struct {\n\tm  map[net.Conn]struct{}\n\tmu sync.Mutex\n}\n\nfunc (m *connections) Add(c net.Conn) {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\tm.m[c] = struct{}{}\n}\n\nfunc (m *connections) Close(c net.Conn) error {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\terr := c.Close()\n\tdelete(m.m, c)\n\treturn err\n}\n\nfunc (m *connections) Size() int {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\treturn len(m.m)\n}\n\nfunc (m *connections) CloseAll() error {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\tvar err error\n\tfor c := range m.m {\n\t\terr = errors.Join(err, c.Close())\n\t\tdelete(m.m, c)\n\t}\n\n\treturn err\n}\n\n// serverConn is a wrapper around a net.Conn that closes the connection when\n// the one of the timeouts is reached.\ntype serverConn struct {\n\tnet.Conn\n\n\tinitTimeout   time.Duration\n\tidleTimeout   time.Duration\n\tmaxDeadline   time.Time\n\tcloseCanceler context.CancelFunc\n}\n\nvar _ net.Conn = (*serverConn)(nil)\n\nfunc (c *serverConn) Write(p []byte) (n int, err error) {\n\tc.updateDeadline()\n\tn, err = c.Conn.Write(p)\n\tif _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil {\n\t\tc.closeCanceler()\n\t}\n\treturn\n}\n\nfunc (c *serverConn) Read(b []byte) (n int, err error) {\n\tc.updateDeadline()\n\tn, err = c.Conn.Read(b)\n\tif _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil {\n\t\tc.closeCanceler()\n\t}\n\treturn\n}\n\nfunc (c *serverConn) Close() (err error) {\n\terr = c.Conn.Close()\n\tif c.closeCanceler != nil {\n\t\tc.closeCanceler()\n\t}\n\treturn\n}\n\nfunc (c *serverConn) updateDeadline() {\n\tswitch {\n\tcase c.initTimeout > 0:\n\t\tinitTimeout := time.Now().Add(c.initTimeout)\n\t\tc.initTimeout = 0\n\t\tif initTimeout.Unix() < c.maxDeadline.Unix() || c.maxDeadline.IsZero() {\n\t\t\tc.Conn.SetDeadline(initTimeout) //nolint: errcheck\n\t\t\treturn\n\t\t}\n\tcase c.idleTimeout > 0:\n\t\tidleDeadline := time.Now().Add(c.idleTimeout)\n\t\tif idleDeadline.Unix() < c.maxDeadline.Unix() || c.maxDeadline.IsZero() {\n\t\t\tc.Conn.SetDeadline(idleDeadline) //nolint: errcheck\n\t\t\treturn\n\t\t}\n\t}\n\tc.Conn.SetDeadline(c.maxDeadline) //nolint: errcheck\n}\n"
  },
  {
    "path": "pkg/daemon/daemon.go",
    "content": "package daemon\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"charm.land/log/v2\"\n\t\"github.com/charmbracelet/soft-serve/pkg/access\"\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/git\"\n\t\"github.com/charmbracelet/soft-serve/pkg/utils\"\n\t\"github.com/go-git/go-git/v5/plumbing/format/pktline\"\n\t\"github.com/prometheus/client_golang/prometheus\"\n\t\"github.com/prometheus/client_golang/prometheus/promauto\"\n)\n\nvar (\n\tuploadPackGitCounter = promauto.NewCounterVec(prometheus.CounterOpts{\n\t\tNamespace: \"soft_serve\",\n\t\tSubsystem: \"git\",\n\t\tName:      \"git_upload_pack_total\",\n\t\tHelp:      \"The total number of git-upload-pack requests\",\n\t}, []string{\"repo\"})\n\n\tuploadArchiveGitCounter = promauto.NewCounterVec(prometheus.CounterOpts{\n\t\tNamespace: \"soft_serve\",\n\t\tSubsystem: \"git\",\n\t\tName:      \"git_upload_archive_total\",\n\t\tHelp:      \"The total number of git-upload-archive requests\",\n\t}, []string{\"repo\"})\n)\n\n// ErrServerClosed indicates that the server has been closed.\nvar ErrServerClosed = fmt.Errorf(\"git: %w\", net.ErrClosed)\n\n// GitDaemon represents a Git daemon.\ntype GitDaemon struct {\n\tctx       context.Context\n\taddr      string\n\tfinished  chan struct{}\n\tconns     connections\n\tcfg       *config.Config\n\tbe        *backend.Backend\n\twg        sync.WaitGroup\n\tonce      sync.Once\n\tlogger    *log.Logger\n\tdone      atomic.Bool // indicates if the server has been closed\n\tlisteners []net.Listener\n\tliMu      sync.Mutex\n}\n\n// NewGitDaemon returns a new Git daemon.\nfunc NewGitDaemon(ctx context.Context) (*GitDaemon, error) {\n\tcfg := config.FromContext(ctx)\n\taddr := cfg.Git.ListenAddr\n\td := &GitDaemon{\n\t\tctx:      ctx,\n\t\taddr:     addr,\n\t\tfinished: make(chan struct{}, 1),\n\t\tcfg:      cfg,\n\t\tbe:       backend.FromContext(ctx),\n\t\tconns:    connections{m: make(map[net.Conn]struct{})},\n\t\tlogger:   log.FromContext(ctx).WithPrefix(\"gitdaemon\"),\n\t}\n\treturn d, nil\n}\n\n// ListenAndServe starts the Git TCP daemon.\nfunc (d *GitDaemon) ListenAndServe() error {\n\tif d.done.Load() {\n\t\treturn ErrServerClosed\n\t}\n\tvar cfg net.ListenConfig\n\tlistener, err := cfg.Listen(d.ctx, \"tcp\", d.addr)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn d.Serve(listener)\n}\n\n// Serve listens on the TCP network address and serves Git requests.\nfunc (d *GitDaemon) Serve(listener net.Listener) error {\n\tif d.done.Load() {\n\t\treturn ErrServerClosed\n\t}\n\n\td.wg.Add(1)\n\tdefer d.wg.Done()\n\td.liMu.Lock()\n\td.listeners = append(d.listeners, listener)\n\td.liMu.Unlock()\n\n\tvar tempDelay time.Duration\n\tfor {\n\t\tconn, err := listener.Accept()\n\t\tif err != nil {\n\t\t\tselect {\n\t\t\tcase <-d.finished:\n\t\t\t\treturn ErrServerClosed\n\t\t\tdefault:\n\t\t\t\td.logger.Debugf(\"git: error accepting connection: %v\", err)\n\t\t\t}\n\t\t\tif ne, ok := err.(net.Error); ok && ne.Temporary() {\n\t\t\t\tif tempDelay == 0 {\n\t\t\t\t\ttempDelay = 5 * time.Millisecond\n\t\t\t\t} else {\n\t\t\t\t\ttempDelay *= 2\n\t\t\t\t}\n\t\t\t\tif max := 1 * time.Second; tempDelay > max { //nolint:revive\n\t\t\t\t\ttempDelay = max\n\t\t\t\t}\n\t\t\t\ttime.Sleep(tempDelay)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\treturn err\n\t\t}\n\n\t\t// Close connection if there are too many open connections.\n\t\tif d.conns.Size()+1 >= d.cfg.Git.MaxConnections {\n\t\t\td.logger.Debugf(\"git: max connections reached, closing %s\", conn.RemoteAddr())\n\t\t\td.fatal(conn, git.ErrMaxConnections)\n\t\t\tcontinue\n\t\t}\n\n\t\td.wg.Add(1)\n\t\tgo func() {\n\t\t\td.handleClient(conn)\n\t\t\td.wg.Done()\n\t\t}()\n\t}\n}\n\nfunc (d *GitDaemon) fatal(c net.Conn, err error) {\n\tgit.WritePktlineErr(c, err) //nolint: errcheck\n\tif err := c.Close(); err != nil {\n\t\td.logger.Debugf(\"git: error closing connection: %v\", err)\n\t}\n}\n\n// handleClient handles a git protocol client.\nfunc (d *GitDaemon) handleClient(conn net.Conn) {\n\tctx, cancel := context.WithCancel(context.Background())\n\tidleTimeout := time.Duration(d.cfg.Git.IdleTimeout) * time.Second\n\tc := &serverConn{\n\t\tConn:          conn,\n\t\tidleTimeout:   idleTimeout,\n\t\tcloseCanceler: cancel,\n\t}\n\tif d.cfg.Git.MaxTimeout > 0 {\n\t\tdur := time.Duration(d.cfg.Git.MaxTimeout) * time.Second\n\t\tc.maxDeadline = time.Now().Add(dur)\n\t}\n\td.conns.Add(c)\n\tdefer func() {\n\t\td.conns.Close(c) //nolint: errcheck\n\t}()\n\n\terrc := make(chan error, 1)\n\n\ts := pktline.NewScanner(c)\n\tgo func() {\n\t\tif !s.Scan() {\n\t\t\tif err := s.Err(); err != nil {\n\t\t\t\terrc <- err\n\t\t\t}\n\t\t}\n\t\terrc <- nil\n\t}()\n\n\tselect {\n\tcase <-ctx.Done():\n\t\tif err := ctx.Err(); err != nil {\n\t\t\td.logger.Debugf(\"git: connection context error: %v\", err)\n\t\t\td.fatal(c, git.ErrTimeout)\n\t\t}\n\t\treturn\n\tcase err := <-errc:\n\t\tif nerr, ok := err.(net.Error); ok && nerr.Timeout() {\n\t\t\td.fatal(c, git.ErrTimeout)\n\t\t\treturn\n\t\t} else if err != nil {\n\t\t\td.logger.Debugf(\"git: error scanning pktline: %v\", err)\n\t\t\td.fatal(c, git.ErrSystemMalfunction)\n\t\t\treturn\n\t\t}\n\n\t\tline := s.Bytes()\n\t\tsplit := bytes.SplitN(line, []byte{' '}, 2)\n\t\tif len(split) != 2 {\n\t\t\td.fatal(c, git.ErrInvalidRequest)\n\t\t\treturn\n\t\t}\n\n\t\tvar counter *prometheus.CounterVec\n\t\tservice := git.Service(split[0])\n\t\tswitch service {\n\t\tcase git.UploadPackService:\n\t\t\tcounter = uploadPackGitCounter\n\t\tcase git.UploadArchiveService:\n\t\t\tcounter = uploadArchiveGitCounter\n\t\tdefault:\n\t\t\td.fatal(c, git.ErrInvalidRequest)\n\t\t\treturn\n\t\t}\n\n\t\topts := bytes.SplitN(split[1], []byte{0}, 3)\n\t\tif len(opts) < 2 {\n\t\t\td.fatal(c, git.ErrInvalidRequest) //nolint: errcheck\n\t\t\treturn\n\t\t}\n\n\t\thost := strings.TrimPrefix(string(opts[1]), \"host=\")\n\t\textraParams := map[string]string{}\n\n\t\tif len(opts) > 2 {\n\t\t\tbuf := bytes.TrimPrefix(opts[2], []byte{0})\n\t\t\tfor _, o := range bytes.Split(buf, []byte{0}) {\n\t\t\t\topt := string(o)\n\t\t\t\tif opt == \"\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tkv := strings.SplitN(opt, \"=\", 2)\n\t\t\t\tif len(kv) != 2 {\n\t\t\t\t\td.logger.Errorf(\"git: invalid option %q\", opt)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\textraParams[kv[0]] = kv[1]\n\t\t\t}\n\n\t\t\tversion := extraParams[\"version\"]\n\t\t\tif version != \"\" {\n\t\t\t\td.logger.Debugf(\"git: protocol version %s\", version)\n\t\t\t}\n\t\t}\n\n\t\tbe := d.be\n\t\tif !be.AllowKeyless(ctx) {\n\t\t\td.fatal(c, git.ErrNotAuthed)\n\t\t\treturn\n\t\t}\n\n\t\tname := utils.SanitizeRepo(string(opts[0]))\n\t\td.logger.Debugf(\"git: connect %s %s %s\", c.RemoteAddr(), service, name)\n\t\tdefer d.logger.Debugf(\"git: disconnect %s %s %s\", c.RemoteAddr(), service, name)\n\n\t\t// git bare repositories should end in \".git\"\n\t\t// https://git-scm.com/docs/gitrepository-layout\n\t\trepo := name + \".git\"\n\t\treposDir := filepath.Join(d.cfg.DataPath, \"repos\")\n\t\tif err := git.EnsureWithin(reposDir, repo); err != nil {\n\t\t\td.logger.Debugf(\"git: error ensuring repo path: %v\", err)\n\t\t\td.fatal(c, git.ErrInvalidRepo)\n\t\t\treturn\n\t\t}\n\n\t\tif _, err := d.be.Repository(ctx, repo); err != nil {\n\t\t\td.fatal(c, git.ErrInvalidRepo)\n\t\t\treturn\n\t\t}\n\n\t\tauth := be.AccessLevel(ctx, name, \"\")\n\t\tif auth < access.ReadOnlyAccess {\n\t\t\td.fatal(c, git.ErrNotAuthed)\n\t\t\treturn\n\t\t}\n\n\t\t// Environment variables to pass down to git hooks.\n\t\tenvs := []string{\n\t\t\t\"SOFT_SERVE_REPO_NAME=\" + name,\n\t\t\t\"SOFT_SERVE_REPO_PATH=\" + filepath.Join(reposDir, repo),\n\t\t\t\"SOFT_SERVE_HOST=\" + host,\n\t\t\t\"SOFT_SERVE_LOG_PATH=\" + filepath.Join(d.cfg.DataPath, \"log\", \"hooks.log\"),\n\t\t}\n\n\t\t// Add git protocol environment variable.\n\t\tif len(extraParams) > 0 {\n\t\t\tvar gitProto string\n\t\t\tfor k, v := range extraParams {\n\t\t\t\tif len(gitProto) > 0 {\n\t\t\t\t\tgitProto += \":\"\n\t\t\t\t}\n\t\t\t\tgitProto += k + \"=\" + v\n\t\t\t}\n\t\t\tenvs = append(envs, \"GIT_PROTOCOL=\"+gitProto)\n\t\t}\n\n\t\tenvs = append(envs, d.cfg.Environ()...)\n\n\t\tcmd := git.ServiceCommand{\n\t\t\tStdin:  c,\n\t\t\tStdout: c,\n\t\t\tStderr: c,\n\t\t\tEnv:    envs,\n\t\t\tDir:    filepath.Join(reposDir, repo),\n\t\t}\n\n\t\tif err := service.Handler(ctx, cmd); err != nil {\n\t\t\td.logger.Debugf(\"git: error handling request: %v\", err)\n\t\t\td.fatal(c, err)\n\t\t\treturn\n\t\t}\n\n\t\tcounter.WithLabelValues(name)\n\t}\n}\n\n// Close closes the underlying listener.\nfunc (d *GitDaemon) Close() error {\n\terr := d.closeListener()\n\td.conns.CloseAll() //nolint: errcheck\n\treturn err\n}\n\n// closeListener closes the listener and the finished channel.\nfunc (d *GitDaemon) closeListener() error {\n\tif d.done.Load() {\n\t\treturn ErrServerClosed\n\t}\n\tvar err error\n\td.liMu.Lock()\n\tfor _, l := range d.listeners {\n\t\tif err = l.Close(); err != nil {\n\t\t\terr = errors.Join(err, fmt.Errorf(\"close listener %s: %w\", l.Addr(), err))\n\t\t}\n\t}\n\td.listeners = d.listeners[:0]\n\td.liMu.Unlock()\n\td.once.Do(func() {\n\t\td.done.Store(true)\n\t\tclose(d.finished)\n\t})\n\treturn err\n}\n\n// Shutdown gracefully shuts down the daemon.\nfunc (d *GitDaemon) Shutdown(ctx context.Context) error {\n\tif d.done.Load() {\n\t\treturn ErrServerClosed\n\t}\n\n\terr := d.closeListener()\n\tfinished := make(chan struct{}, 1)\n\tgo func() {\n\t\tdefer close(finished)\n\t\td.wg.Wait()\n\t}()\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\tcase <-finished:\n\t\treturn err\n\t}\n}\n"
  },
  {
    "path": "pkg/daemon/daemon_test.go",
    "content": "package daemon\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"net\"\n\t\"os\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db/migrate\"\n\t\"github.com/charmbracelet/soft-serve/pkg/git\"\n\t\"github.com/charmbracelet/soft-serve/pkg/store\"\n\t\"github.com/charmbracelet/soft-serve/pkg/store/database\"\n\t\"github.com/charmbracelet/soft-serve/pkg/test\"\n\t\"github.com/go-git/go-git/v5/plumbing/format/pktline\"\n\t_ \"modernc.org/sqlite\" // sqlite driver\n)\n\nvar testDaemon *GitDaemon\n\nfunc TestMain(m *testing.M) {\n\ttmp, err := os.MkdirTemp(\"\", \"soft-serve-test\")\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tmp)\n\tctx := context.TODO()\n\tcfg := config.DefaultConfig()\n\tcfg.DataPath = tmp\n\tcfg.Git.MaxConnections = 3\n\tcfg.Git.MaxTimeout = 100\n\tcfg.Git.IdleTimeout = 1\n\tcfg.Git.ListenAddr = fmt.Sprintf(\":%d\", test.RandomPort())\n\tif err := cfg.Validate(); err != nil {\n\t\tlog.Fatal(err)\n\t}\n\tctx = config.WithContext(ctx, cfg)\n\tdbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource)\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\tdefer dbx.Close() //nolint: errcheck\n\tif err := migrate.Migrate(ctx, dbx); err != nil {\n\t\tlog.Fatal(err)\n\t}\n\tdatastore := database.New(ctx, dbx)\n\tctx = store.WithContext(ctx, datastore)\n\tbe := backend.New(ctx, cfg, dbx, datastore)\n\tctx = backend.WithContext(ctx, be)\n\td, err := NewGitDaemon(ctx)\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\ttestDaemon = d\n\tgo d.ListenAndServe() //nolint:errcheck\n\tcode := m.Run()\n\tos.Unsetenv(\"SOFT_SERVE_DATA_PATH\")\n\tos.Unsetenv(\"SOFT_SERVE_GIT_MAX_CONNECTIONS\")\n\tos.Unsetenv(\"SOFT_SERVE_GIT_MAX_TIMEOUT\")\n\tos.Unsetenv(\"SOFT_SERVE_GIT_IDLE_TIMEOUT\")\n\tos.Unsetenv(\"SOFT_SERVE_GIT_LISTEN_ADDR\")\n\t_ = d.Close()\n\t_ = dbx.Close()\n\tos.Exit(code)\n}\n\nfunc TestIdleTimeout(t *testing.T) {\n\tvar err error\n\tvar c net.Conn\n\tvar tries int\n\tvar dialer net.Dialer\n\tfor {\n\t\tc, err = dialer.DialContext(t.Context(), \"tcp\", testDaemon.addr)\n\t\tif err != nil && tries >= 3 {\n\t\t\tt.Fatalf(\"failed to connect to daemon after %d tries: %v\", tries, err)\n\t\t}\n\t\ttries++\n\t\tif testDaemon.conns.Size() != 0 {\n\t\t\tbreak\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\ttime.Sleep(2 * time.Second)\n\t_, err = readPktline(c)\n\tif err == nil {\n\t\tt.Errorf(\"expected error, got nil\")\n\t}\n}\n\nfunc TestInvalidRepo(t *testing.T) {\n\tc, err := net.Dial(\"tcp\", testDaemon.addr) //nolint:noctx\n\tif err != nil {\n\t\tt.Fatalf(\"failed to connect to daemon: %v\", err)\n\t}\n\tif err := pktline.NewEncoder(c).EncodeString(\"git-upload-pack /test.git\\x00\"); err != nil {\n\t\tt.Fatalf(\"expected nil, got error: %v\", err)\n\t}\n\t_, err = readPktline(c)\n\tif err != nil && err.Error() != git.ErrInvalidRepo.Error() {\n\t\tt.Errorf(\"expected %q error, got %q\", git.ErrInvalidRepo, err)\n\t}\n}\n\nfunc readPktline(c net.Conn) (string, error) {\n\tpktout := pktline.NewScanner(c)\n\tif !pktout.Scan() {\n\t\treturn \"\", pktout.Err()\n\t}\n\treturn strings.TrimSpace(string(pktout.Bytes())), nil\n}\n"
  },
  {
    "path": "pkg/db/context.go",
    "content": "package db\n\nimport \"context\"\n\n// ContextKey is the key used to store the database in the context.\nvar ContextKey = struct{ string }{\"db\"}\n\n// FromContext returns the database from the context.\nfunc FromContext(ctx context.Context) *DB {\n\tif db, ok := ctx.Value(ContextKey).(*DB); ok {\n\t\treturn db\n\t}\n\treturn nil\n}\n\n// WithContext returns a new context with the database.\nfunc WithContext(ctx context.Context, db *DB) context.Context {\n\treturn context.WithValue(ctx, ContextKey, db)\n}\n"
  },
  {
    "path": "pkg/db/context_test.go",
    "content": "package db_test\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db/internal/test\"\n)\n\nfunc TestBadFromContext(t *testing.T) {\n\tctx := context.TODO()\n\tif c := db.FromContext(ctx); c != nil {\n\t\tt.Errorf(\"FromContext(ctx) => %v, want %v\", c, nil)\n\t}\n}\n\nfunc TestGoodFromContext(t *testing.T) {\n\tctx := context.TODO()\n\tdbx, err := test.OpenSqlite(ctx, t)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tctx = db.WithContext(ctx, dbx)\n\tif c := db.FromContext(ctx); c == nil {\n\t\tt.Errorf(\"FromContext(ctx) => %v, want %v\", c, dbx)\n\t}\n}\n"
  },
  {
    "path": "pkg/db/db.go",
    "content": "package db\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\n\t\"charm.land/log/v2\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/jmoiron/sqlx\"\n\t_ \"github.com/lib/pq\"  // postgres driver\n\t_ \"modernc.org/sqlite\" // sqlite driver\n)\n\n// DB is the interface for a Soft Serve database.\ntype DB struct {\n\t*sqlx.DB\n\tlogger *log.Logger\n}\n\n// Open opens a database connection.\nfunc Open(ctx context.Context, driverName string, dsn string) (*DB, error) {\n\tdb, err := sqlx.ConnectContext(ctx, driverName, dsn)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\td := &DB{\n\t\tDB: db,\n\t}\n\n\tif config.IsVerbose() {\n\t\tlogger := log.FromContext(ctx).WithPrefix(\"db\")\n\t\td.logger = logger\n\t}\n\n\treturn d, nil\n}\n\n// Close implements db.DB.\nfunc (d *DB) Close() error {\n\treturn d.DB.Close()\n}\n\n// Tx is a database transaction.\ntype Tx struct {\n\t*sqlx.Tx\n\tlogger *log.Logger\n}\n\n// Transaction implements db.DB.\nfunc (d *DB) Transaction(fn func(tx *Tx) error) error {\n\treturn d.TransactionContext(context.Background(), fn)\n}\n\n// TransactionContext implements db.DB.\nfunc (d *DB) TransactionContext(ctx context.Context, fn func(tx *Tx) error) error {\n\ttxx, err := d.DB.BeginTxx(ctx, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to begin transaction: %w\", err)\n\t}\n\n\ttx := &Tx{txx, d.logger}\n\tif err := fn(tx); err != nil {\n\t\treturn rollback(tx, err)\n\t}\n\n\tif err := tx.Commit(); err != nil {\n\t\tif errors.Is(err, sql.ErrTxDone) {\n\t\t\t// this is ok because whoever did finish the tx should have also written the error already.\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"failed to commit transaction: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc rollback(tx *Tx, err error) error {\n\tif rerr := tx.Rollback(); rerr != nil {\n\t\tif errors.Is(rerr, sql.ErrTxDone) {\n\t\t\treturn err\n\t\t}\n\t\treturn fmt.Errorf(\"failed to rollback: %s: %w\", err.Error(), rerr)\n\t}\n\n\treturn err\n}\n"
  },
  {
    "path": "pkg/db/db_test.go",
    "content": "package db\n\nimport (\n\t\"context\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestOpenUnknownDriver(t *testing.T) {\n\t_, err := Open(context.TODO(), \"invalid\", \"\")\n\tif err == nil {\n\t\tt.Error(\"Open(invalid) => nil, want error\")\n\t}\n\tif !strings.Contains(err.Error(), \"unknown driver\") {\n\t\tt.Errorf(\"Open(invalid) => %v, want error containing 'unknown driver'\", err)\n\t}\n}\n"
  },
  {
    "path": "pkg/db/errors.go",
    "content": "package db\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\n\t\"github.com/lib/pq\"\n\tsqlite \"modernc.org/sqlite\"\n\tsqlitelib \"modernc.org/sqlite/lib\"\n)\n\nvar (\n\t// ErrDuplicateKey is a constraint violation error.\n\tErrDuplicateKey = errors.New(\"duplicate key value violates table constraint\")\n\n\t// ErrRecordNotFound is returned when a record is not found.\n\tErrRecordNotFound = sql.ErrNoRows\n)\n\n// WrapError is a convenient function that unite various database driver\n// errors to consistent errors.\nfunc WrapError(err error) error {\n\tif err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn ErrRecordNotFound\n\t\t}\n\n\t\t// Handle sqlite constraint error.\n\t\tif liteErr, ok := err.(*sqlite.Error); ok {\n\t\t\tcode := liteErr.Code()\n\t\t\tif code == sqlitelib.SQLITE_CONSTRAINT_PRIMARYKEY ||\n\t\t\t\tcode == sqlitelib.SQLITE_CONSTRAINT_FOREIGNKEY ||\n\t\t\t\tcode == sqlitelib.SQLITE_CONSTRAINT_UNIQUE {\n\t\t\t\treturn ErrDuplicateKey\n\t\t\t}\n\t\t}\n\n\t\t// Handle postgres constraint error.\n\t\tif pgErr, ok := err.(*pq.Error); ok {\n\t\t\tif pgErr.Code == \"23505\" ||\n\t\t\t\tpgErr.Code == \"23503\" ||\n\t\t\t\tpgErr.Code == \"23514\" {\n\t\t\t\treturn ErrDuplicateKey\n\t\t\t}\n\t\t}\n\t}\n\treturn err\n}\n"
  },
  {
    "path": "pkg/db/errors_test.go",
    "content": "package db\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"testing\"\n)\n\nfunc TestWrapErrorBadNoRows(t *testing.T) {\n\tfor _, e := range []error{\n\t\tfmt.Errorf(\"foo\"),\n\t\terrors.New(\"bar\"),\n\t} {\n\t\tif err := WrapError(e); err != e {\n\t\t\tt.Errorf(\"WrapError(%v) => %v, want %v\", e, err, e)\n\t\t}\n\t}\n}\n\nfunc TestWrapErrorGoodNoRows(t *testing.T) {\n\tif err := WrapError(sql.ErrNoRows); err != ErrRecordNotFound {\n\t\tt.Errorf(\"WrapError(sql.ErrNoRows) => %v, want %v\", err, ErrRecordNotFound)\n\t}\n}\n"
  },
  {
    "path": "pkg/db/handler.go",
    "content": "package db\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\n\t\"github.com/jmoiron/sqlx\"\n)\n\n// Handler is a database handler.\ntype Handler interface {\n\tRebind(string) string\n\n\tSelect(interface{}, string, ...interface{}) error\n\tGet(interface{}, string, ...interface{}) error\n\tQueryx(string, ...interface{}) (*sqlx.Rows, error)\n\tQueryRowx(string, ...interface{}) *sqlx.Row\n\tExec(string, ...interface{}) (sql.Result, error)\n\n\tSelectContext(context.Context, interface{}, string, ...interface{}) error\n\tGetContext(context.Context, interface{}, string, ...interface{}) error\n\tQueryxContext(context.Context, string, ...interface{}) (*sqlx.Rows, error)\n\tQueryRowxContext(context.Context, string, ...interface{}) *sqlx.Row\n\tExecContext(context.Context, string, ...interface{}) (sql.Result, error)\n}\n"
  },
  {
    "path": "pkg/db/internal/test/test.go",
    "content": "package test\n\nimport (\n\t\"context\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n)\n\n// OpenSqlite opens a new temp SQLite database for testing.\n// It removes the database file when the test is done using tb.Cleanup.\n// If ctx is nil, context.TODO() is used.\nfunc OpenSqlite(ctx context.Context, tb testing.TB) (*db.DB, error) {\n\tif ctx == nil {\n\t\tctx = context.TODO()\n\t}\n\tdbpath := filepath.Join(tb.TempDir(), \"test.db\")\n\tdbx, err := db.Open(ctx, \"sqlite\", dbpath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ttb.Cleanup(func() {\n\t\tif err := dbx.Close(); err != nil {\n\t\t\ttb.Error(err)\n\t\t}\n\t})\n\treturn dbx, nil\n}\n"
  },
  {
    "path": "pkg/db/logger.go",
    "content": "package db\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"strings\"\n\n\t\"charm.land/log/v2\"\n\t\"github.com/jmoiron/sqlx\"\n)\n\nfunc trace(l *log.Logger, query string, args ...interface{}) {\n\tif l != nil {\n\t\t// Remove newlines and tabs\n\t\tquery = strings.ReplaceAll(query, \"\\t\", \"\")\n\t\tquery = strings.TrimSpace(query)\n\t\tl.Debug(\"trace\", \"query\", query, \"args\", args)\n\t}\n}\n\n// Select is a wrapper around sqlx.Select that logs the query and arguments.\nfunc (d *DB) Select(dest interface{}, query string, args ...interface{}) error {\n\ttrace(d.logger, query, args...)\n\treturn d.DB.Select(dest, query, args...)\n}\n\n// Get is a wrapper around sqlx.Get that logs the query and arguments.\nfunc (d *DB) Get(dest interface{}, query string, args ...interface{}) error {\n\ttrace(d.logger, query, args...)\n\treturn d.DB.Get(dest, query, args...)\n}\n\n// Queryx is a wrapper around sqlx.Queryx that logs the query and arguments.\nfunc (d *DB) Queryx(query string, args ...interface{}) (*sqlx.Rows, error) {\n\ttrace(d.logger, query, args...)\n\treturn d.DB.Queryx(query, args...)\n}\n\n// QueryRowx is a wrapper around sqlx.QueryRowx that logs the query and arguments.\nfunc (d *DB) QueryRowx(query string, args ...interface{}) *sqlx.Row {\n\ttrace(d.logger, query, args...)\n\treturn d.DB.QueryRowx(query, args...)\n}\n\n// Exec is a wrapper around sqlx.Exec that logs the query and arguments.\n//\n// Deprecated: Use [DB.ExecContext] instead.\nfunc (d *DB) Exec(query string, args ...interface{}) (sql.Result, error) {\n\ttrace(d.logger, query, args...)\n\treturn d.DB.Exec(query, args...) //nolint:noctx\n}\n\n// SelectContext is a wrapper around sqlx.SelectContext that logs the query and arguments.\nfunc (d *DB) SelectContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error {\n\ttrace(d.logger, query, args...)\n\treturn d.DB.SelectContext(ctx, dest, query, args...)\n}\n\n// GetContext is a wrapper around sqlx.GetContext that logs the query and arguments.\nfunc (d *DB) GetContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error {\n\ttrace(d.logger, query, args...)\n\treturn d.DB.GetContext(ctx, dest, query, args...)\n}\n\n// QueryxContext is a wrapper around sqlx.QueryxContext that logs the query and arguments.\nfunc (d *DB) QueryxContext(ctx context.Context, query string, args ...interface{}) (*sqlx.Rows, error) {\n\ttrace(d.logger, query, args...)\n\treturn d.DB.QueryxContext(ctx, query, args...)\n}\n\n// QueryRowxContext is a wrapper around sqlx.QueryRowxContext that logs the query and arguments.\nfunc (d *DB) QueryRowxContext(ctx context.Context, query string, args ...interface{}) *sqlx.Row {\n\ttrace(d.logger, query, args...)\n\treturn d.DB.QueryRowxContext(ctx, query, args...)\n}\n\n// ExecContext is a wrapper around sqlx.ExecContext that logs the query and arguments.\nfunc (d *DB) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {\n\ttrace(d.logger, query, args...)\n\treturn d.DB.ExecContext(ctx, query, args...)\n}\n\n// Select is a wrapper around sqlx.Select that logs the query and arguments.\nfunc (t *Tx) Select(dest interface{}, query string, args ...interface{}) error {\n\ttrace(t.logger, query, args...)\n\treturn t.Tx.Select(dest, query, args...)\n}\n\n// Get is a wrapper around sqlx.Get that logs the query and arguments.\nfunc (t *Tx) Get(dest interface{}, query string, args ...interface{}) error {\n\ttrace(t.logger, query, args...)\n\treturn t.Tx.Get(dest, query, args...)\n}\n\n// Queryx is a wrapper around sqlx.Queryx that logs the query and arguments.\nfunc (t *Tx) Queryx(query string, args ...interface{}) (*sqlx.Rows, error) {\n\ttrace(t.logger, query, args...)\n\treturn t.Tx.Queryx(query, args...)\n}\n\n// QueryRowx is a wrapper around sqlx.QueryRowx that logs the query and arguments.\nfunc (t *Tx) QueryRowx(query string, args ...interface{}) *sqlx.Row {\n\ttrace(t.logger, query, args...)\n\treturn t.Tx.QueryRowx(query, args...)\n}\n\n// Exec is a wrapper around sqlx.Exec that logs the query and arguments.\n//\n// Deprecated: Use [Tx.ExecContext] instead.\nfunc (t *Tx) Exec(query string, args ...interface{}) (sql.Result, error) {\n\ttrace(t.logger, query, args...)\n\treturn t.Tx.Exec(query, args...) //nolint:noctx\n}\n\n// SelectContext is a wrapper around sqlx.SelectContext that logs the query and arguments.\nfunc (t *Tx) SelectContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error {\n\ttrace(t.logger, query, args...)\n\treturn t.Tx.SelectContext(ctx, dest, query, args...)\n}\n\n// GetContext is a wrapper around sqlx.GetContext that logs the query and arguments.\nfunc (t *Tx) GetContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error {\n\ttrace(t.logger, query, args...)\n\treturn t.Tx.GetContext(ctx, dest, query, args...)\n}\n\n// QueryxContext is a wrapper around sqlx.QueryxContext that logs the query and arguments.\nfunc (t *Tx) QueryxContext(ctx context.Context, query string, args ...interface{}) (*sqlx.Rows, error) {\n\ttrace(t.logger, query, args...)\n\treturn t.Tx.QueryxContext(ctx, query, args...)\n}\n\n// QueryRowxContext is a wrapper around sqlx.QueryRowxContext that logs the query and arguments.\nfunc (t *Tx) QueryRowxContext(ctx context.Context, query string, args ...interface{}) *sqlx.Row {\n\ttrace(t.logger, query, args...)\n\treturn t.Tx.QueryRowxContext(ctx, query, args...)\n}\n\n// ExecContext is a wrapper around sqlx.ExecContext that logs the query and arguments.\nfunc (t *Tx) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {\n\ttrace(t.logger, query, args...)\n\treturn t.Tx.ExecContext(ctx, query, args...)\n}\n"
  },
  {
    "path": "pkg/db/migrate/0001_create_tables.go",
    "content": "package migrate\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strconv\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/access\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/sshutils\"\n)\n\nconst (\n\tcreateTablesName    = \"create tables\"\n\tcreateTablesVersion = 1\n)\n\nvar createTables = Migration{\n\tVersion: createTablesVersion,\n\tName:    createTablesName,\n\tMigrate: func(ctx context.Context, tx *db.Tx) error {\n\t\tcfg := config.FromContext(ctx)\n\n\t\tinsert := \"INSERT \"\n\n\t\t// Alter old tables (if exist)\n\t\t// This is to support prior versions of Soft Serve v0.6\n\t\tswitch tx.DriverName() {\n\t\tcase \"sqlite3\", \"sqlite\":\n\t\t\tinsert += \"OR IGNORE \"\n\n\t\t\thasUserTable := hasTable(tx, \"user\")\n\t\t\tif hasUserTable {\n\t\t\t\tif _, err := tx.ExecContext(ctx, \"ALTER TABLE user RENAME TO user_old\"); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif hasTable(tx, \"public_key\") {\n\t\t\t\tif _, err := tx.ExecContext(ctx, \"ALTER TABLE public_key RENAME TO public_key_old\"); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif hasTable(tx, \"collab\") {\n\t\t\t\tif _, err := tx.ExecContext(ctx, \"ALTER TABLE collab RENAME TO collab_old\"); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif hasTable(tx, \"repo\") {\n\t\t\t\tif _, err := tx.ExecContext(ctx, \"ALTER TABLE repo RENAME TO repo_old\"); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif err := migrateUp(ctx, tx, createTablesVersion, createTablesName); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tswitch tx.DriverName() {\n\t\tcase \"sqlite3\", \"sqlite\":\n\n\t\t\tif _, err := tx.ExecContext(ctx, \"PRAGMA foreign_keys = OFF\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif hasTable(tx, \"user_old\") {\n\t\t\t\tsqlm := `\n\t\t\t\tINSERT INTO users (id, username, admin, updated_at)\n\t\t\t\t\tSELECT id, username, admin, updated_at FROM user_old;\n\t\t\t\t`\n\t\t\t\tif _, err := tx.ExecContext(ctx, sqlm); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif hasTable(tx, \"public_key_old\") {\n\t\t\t\t// Check duplicate keys\n\t\t\t\tpks := []struct {\n\t\t\t\t\tID        string `db:\"id\"`\n\t\t\t\t\tPublicKey string `db:\"public_key\"`\n\t\t\t\t}{}\n\t\t\t\tif err := tx.SelectContext(ctx, &pks, \"SELECT id, public_key FROM public_key_old\"); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tpkss := map[string]struct{}{}\n\t\t\t\tfor _, pk := range pks {\n\t\t\t\t\tif _, ok := pkss[pk.PublicKey]; ok {\n\t\t\t\t\t\treturn fmt.Errorf(\"duplicate public key: %q, please remove the duplicate key and try again\", pk.PublicKey)\n\t\t\t\t\t}\n\t\t\t\t\tpkss[pk.PublicKey] = struct{}{}\n\t\t\t\t}\n\n\t\t\t\tsqlm := `\n\t\t\t\tINSERT INTO public_keys (id, user_id, public_key, created_at, updated_at)\n\t\t\t\t\tSELECT id, user_id, public_key, created_at, updated_at FROM public_key_old;\n\t\t\t\t`\n\t\t\t\tif _, err := tx.ExecContext(ctx, sqlm); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif hasTable(tx, \"repo_old\") {\n\t\t\t\tsqlm := `\n\t\t\t\tINSERT INTO repos (id, name, project_name, description, private,mirror, hidden, created_at, updated_at, user_id)\n\t\t\t\t\tSELECT id, name, project_name, description, private, mirror, hidden, created_at, updated_at, (\n\t\t\t\t\t\tSELECT id FROM users WHERE admin = true ORDER BY id LIMIT 1\n\t\t\t\t) FROM repo_old;\n\t\t\t\t`\n\t\t\t\tif _, err := tx.ExecContext(ctx, sqlm); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif hasTable(tx, \"collab_old\") {\n\t\t\t\tsqlm := `\n\t\t\t\tINSERT INTO collabs (id, user_id, repo_id, access_level, created_at, updated_at)\n\t\t\t\t\tSELECT id, user_id, repo_id, ` + strconv.Itoa(int(access.ReadWriteAccess)) + `, created_at, updated_at FROM collab_old;\n\t\t\t\t`\n\t\t\t\tif _, err := tx.ExecContext(ctx, sqlm); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif _, err := tx.ExecContext(ctx, \"PRAGMA foreign_keys = ON\"); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\t// Insert default user\n\t\tinsertUser := tx.Rebind(insert + \"INTO users (username, admin, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)\")\n\t\tif _, err := tx.ExecContext(ctx, insertUser, \"admin\", true); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor _, k := range cfg.AdminKeys() {\n\t\t\tquery := insert + \"INTO public_keys (user_id, public_key, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)\"\n\t\t\tif tx.DriverName() == \"postgres\" {\n\t\t\t\tquery += \" ON CONFLICT DO NOTHING\"\n\t\t\t}\n\n\t\t\tquery = tx.Rebind(query)\n\t\t\tak := sshutils.MarshalAuthorizedKey(k)\n\t\t\tif _, err := tx.ExecContext(ctx, query, 1, ak); err != nil {\n\t\t\t\tif errors.Is(db.WrapError(err), db.ErrDuplicateKey) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\t// Insert default settings\n\t\tinsertSettings := insert + \"INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)\"\n\t\tinsertSettings = tx.Rebind(insertSettings)\n\t\tsettings := []struct {\n\t\t\tKey   string\n\t\t\tValue string\n\t\t}{\n\t\t\t{\"allow_keyless\", \"true\"},\n\t\t\t{\"anon_access\", access.ReadOnlyAccess.String()},\n\t\t\t{\"init\", \"true\"},\n\t\t}\n\n\t\tfor _, s := range settings {\n\t\t\tif _, err := tx.ExecContext(ctx, insertSettings, s.Key, s.Value); err != nil {\n\t\t\t\treturn fmt.Errorf(\"inserting default settings %q: %w\", s.Key, err)\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t},\n\tRollback: func(ctx context.Context, tx *db.Tx) error {\n\t\treturn migrateDown(ctx, tx, createTablesVersion, createTablesName)\n\t},\n}\n"
  },
  {
    "path": "pkg/db/migrate/0001_create_tables_postgres.down.sql",
    "content": ""
  },
  {
    "path": "pkg/db/migrate/0001_create_tables_postgres.up.sql",
    "content": "CREATE TABLE IF NOT EXISTS settings (\n  id SERIAL PRIMARY KEY,\n  key TEXT NOT NULL UNIQUE,\n  value TEXT NOT NULL,\n  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  updated_at TIMESTAMP NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS users (\n  id SERIAL PRIMARY KEY,\n  username TEXT NOT NULL UNIQUE,\n  admin BOOLEAN NOT NULL,\n  password TEXT,\n  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  updated_at TIMESTAMP NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS public_keys (\n  id SERIAL PRIMARY KEY,\n  user_id INTEGER NOT NULL,\n  public_key TEXT NOT NULL UNIQUE,\n  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  updated_at TIMESTAMP NOT NULL,\n  CONSTRAINT user_id_fk\n  FOREIGN KEY(user_id) REFERENCES users(id)\n  ON DELETE CASCADE\n  ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS repos (\n  id SERIAL PRIMARY KEY,\n  name TEXT NOT NULL UNIQUE,\n  project_name TEXT NOT NULL,\n  description TEXT NOT NULL,\n  private BOOLEAN NOT NULL,\n  mirror BOOLEAN NOT NULL,\n  hidden BOOLEAN NOT NULL,\n  user_id INTEGER NOT NULL,\n  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  updated_at TIMESTAMP NOT NULL,\n  CONSTRAINT user_id_fk\n  FOREIGN KEY(user_id) REFERENCES users(id)\n  ON DELETE CASCADE\n  ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS collabs (\n  id SERIAL PRIMARY KEY,\n  user_id INTEGER NOT NULL,\n  repo_id INTEGER NOT NULL,\n  access_level INTEGER NOT NULL,\n  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  updated_at TIMESTAMP NOT NULL,\n  UNIQUE (user_id, repo_id),\n  CONSTRAINT user_id_fk\n  FOREIGN KEY(user_id) REFERENCES users(id)\n  ON DELETE CASCADE\n  ON UPDATE CASCADE,\n  CONSTRAINT repo_id_fk\n  FOREIGN KEY(repo_id) REFERENCES repos(id)\n  ON DELETE CASCADE\n  ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS lfs_objects (\n  id SERIAL PRIMARY KEY,\n  oid TEXT NOT NULL,\n  size INTEGER NOT NULL,\n  repo_id INTEGER NOT NULL,\n  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  updated_at TIMESTAMP NOT NULL,\n  UNIQUE (oid, repo_id),\n  CONSTRAINT repo_id_fk\n  FOREIGN KEY(repo_id) REFERENCES repos(id)\n  ON DELETE CASCADE\n  ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS lfs_locks (\n  id SERIAL PRIMARY KEY,\n  repo_id INTEGER NOT NULL,\n  user_id INTEGER NOT NULL,\n  path TEXT NOT NULL,\n  refname TEXT,\n  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  updated_at TIMESTAMP NOT NULL,\n  UNIQUE (repo_id, path),\n  CONSTRAINT repo_id_fk\n  FOREIGN KEY(repo_id) REFERENCES repos(id)\n  ON DELETE CASCADE\n  ON UPDATE CASCADE,\n  CONSTRAINT user_id_fk\n  FOREIGN KEY(user_id) REFERENCES users(id)\n  ON DELETE CASCADE\n  ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS access_tokens (\n  id SERIAL PRIMARY KEY,\n  name text NOT NULL,\n  token TEXT NOT NULL UNIQUE,\n  user_id INTEGER NOT NULL,\n  expires_at TIMESTAMP,\n  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  updated_at TIMESTAMP NOT NULL,\n  CONSTRAINT user_id_fk\n  FOREIGN KEY (user_id) REFERENCES users(id)\n  ON DELETE CASCADE\n  ON UPDATE CASCADE\n);\n"
  },
  {
    "path": "pkg/db/migrate/0001_create_tables_sqlite.down.sql",
    "content": ""
  },
  {
    "path": "pkg/db/migrate/0001_create_tables_sqlite.up.sql",
    "content": "CREATE TABLE IF NOT EXISTS settings (\n  id INTEGER PRIMARY KEY AUTOINCREMENT,\n  key TEXT NOT NULL UNIQUE,\n  value TEXT NOT NULL,\n  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  updated_at DATETIME NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS users (\n  id INTEGER PRIMARY KEY AUTOINCREMENT,\n  username TEXT NOT NULL UNIQUE,\n  admin BOOLEAN NOT NULL,\n  password TEXT,\n  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  updated_at DATETIME NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS public_keys (\n  id INTEGER PRIMARY KEY AUTOINCREMENT,\n  user_id INTEGER NOT NULL,\n  public_key TEXT NOT NULL UNIQUE,\n  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  updated_at DATETIME NOT NULL,\n  CONSTRAINT user_id_fk\n  FOREIGN KEY(user_id) REFERENCES users(id)\n  ON DELETE CASCADE\n  ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS repos (\n  id INTEGER PRIMARY KEY AUTOINCREMENT,\n  name TEXT NOT NULL UNIQUE,\n  project_name TEXT NOT NULL,\n  description TEXT NOT NULL,\n  private BOOLEAN NOT NULL,\n  mirror BOOLEAN NOT NULL,\n  hidden BOOLEAN NOT NULL,\n  user_id INTEGER NOT NULL,\n  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  updated_at DATETIME NOT NULL,\n  CONSTRAINT user_id_fk\n  FOREIGN KEY(user_id) REFERENCES users(id)\n  ON DELETE CASCADE\n  ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS collabs (\n  id INTEGER PRIMARY KEY AUTOINCREMENT,\n  user_id INTEGER NOT NULL,\n  repo_id INTEGER NOT NULL,\n  access_level INTEGER NOT NULL,\n  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  updated_at DATETIME NOT NULL,\n  UNIQUE (user_id, repo_id),\n  CONSTRAINT user_id_fk\n  FOREIGN KEY(user_id) REFERENCES users(id)\n  ON DELETE CASCADE\n  ON UPDATE CASCADE,\n  CONSTRAINT repo_id_fk\n  FOREIGN KEY(repo_id) REFERENCES repos(id)\n  ON DELETE CASCADE\n  ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS lfs_objects (\n  id INTEGER PRIMARY KEY AUTOINCREMENT,\n  oid TEXT NOT NULL,\n  size INTEGER NOT NULL,\n  repo_id INTEGER NOT NULL,\n  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  updated_at DATETIME NOT NULL,\n  UNIQUE (oid, repo_id),\n  CONSTRAINT repo_id_fk\n  FOREIGN KEY(repo_id) REFERENCES repos(id)\n  ON DELETE CASCADE\n  ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS lfs_locks (\n  id INTEGER PRIMARY KEY AUTOINCREMENT,\n  repo_id INTEGER NOT NULL,\n  user_id INTEGER NOT NULL,\n  path TEXT NOT NULL,\n  refname TEXT,\n  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  updated_at DATETIME NOT NULL,\n  UNIQUE (repo_id, path),\n  CONSTRAINT repo_id_fk\n  FOREIGN KEY(repo_id) REFERENCES repos(id)\n  ON DELETE CASCADE\n  ON UPDATE CASCADE,\n  CONSTRAINT user_id_fk\n  FOREIGN KEY(user_id) REFERENCES users(id)\n  ON DELETE CASCADE\n  ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS access_tokens (\n  id INTEGER primary key autoincrement,\n  token text NOT NULL UNIQUE,\n  name text NOT NULL,\n  user_id INTEGER NOT NULL,\n  expires_at DATETIME,\n  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  updated_at DATETIME NOT NULL,\n  CONSTRAINT user_id_fk\n  FOREIGN KEY (user_id) REFERENCES users(id)\n  ON DELETE CASCADE\n  ON UPDATE CASCADE\n);\n"
  },
  {
    "path": "pkg/db/migrate/0002_webhooks.go",
    "content": "package migrate\n\nimport (\n\t\"context\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n)\n\nconst (\n\twebhooksName    = \"webhooks\"\n\twebhooksVersion = 2\n)\n\nvar webhooks = Migration{\n\tName:    webhooksName,\n\tVersion: webhooksVersion,\n\tMigrate: func(ctx context.Context, tx *db.Tx) error {\n\t\treturn migrateUp(ctx, tx, webhooksVersion, webhooksName)\n\t},\n\tRollback: func(ctx context.Context, tx *db.Tx) error {\n\t\treturn migrateDown(ctx, tx, webhooksVersion, webhooksName)\n\t},\n}\n"
  },
  {
    "path": "pkg/db/migrate/0002_webhooks_postgres.down.sql",
    "content": ""
  },
  {
    "path": "pkg/db/migrate/0002_webhooks_postgres.up.sql",
    "content": "CREATE TABLE IF NOT EXISTS webhooks (\n  id SERIAL PRIMARY KEY,\n  repo_id INTEGER NOT NULL,\n  url TEXT NOT NULL,\n  secret TEXT NOT NULL,\n  content_type INTEGER NOT NULL,\n  active BOOLEAN NOT NULL,\n  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  updated_at TIMESTAMP NOT NULL,\n  UNIQUE (repo_id, url),\n  CONSTRAINT repo_id_fk\n  FOREIGN KEY(repo_id) REFERENCES repos(id)\n  ON DELETE CASCADE\n  ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS webhook_events (\n  id SERIAL PRIMARY KEY,\n  webhook_id INTEGER NOT NULL,\n  event INTEGER NOT NULL,\n  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  UNIQUE (webhook_id, event),\n  CONSTRAINT webhook_id_fk\n  FOREIGN KEY(webhook_id) REFERENCES webhooks(id)\n  ON DELETE CASCADE\n  ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS webhook_deliveries (\n  id TEXT PRIMARY KEY,\n  webhook_id INTEGER NOT NULL,\n  event INTEGER NOT NULL,\n  request_url TEXT NOT NULL,\n  request_method TEXT NOT NULL,\n  request_error TEXT,\n  request_headers TEXT NOT NULL,\n  request_body TEXT NOT NULL,\n  response_status INTEGER NOT NULL,\n  response_headers TEXT NOT NULL,\n  response_body TEXT NOT NULL,\n  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  CONSTRAINT webhook_id_fk\n  FOREIGN KEY(webhook_id) REFERENCES webhooks(id)\n  ON DELETE CASCADE\n  ON UPDATE CASCADE\n);\n"
  },
  {
    "path": "pkg/db/migrate/0002_webhooks_sqlite.down.sql",
    "content": ""
  },
  {
    "path": "pkg/db/migrate/0002_webhooks_sqlite.up.sql",
    "content": "CREATE TABLE IF NOT EXISTS webhooks (\n  id INTEGER PRIMARY KEY AUTOINCREMENT,\n  repo_id INTEGER NOT NULL,\n  url TEXT NOT NULL,\n  secret TEXT NOT NULL,\n  content_type INTEGER NOT NULL,\n  active BOOLEAN NOT NULL,\n  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  updated_at DATETIME NOT NULL,\n  UNIQUE (repo_id, url),\n  CONSTRAINT repo_id_fk\n  FOREIGN KEY(repo_id) REFERENCES repos(id)\n  ON DELETE CASCADE\n  ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS webhook_events (\n  id INTEGER PRIMARY KEY AUTOINCREMENT,\n  webhook_id INTEGER NOT NULL,\n  event INTEGER NOT NULL,\n  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  UNIQUE (webhook_id, event),\n  CONSTRAINT webhook_id_fk\n  FOREIGN KEY(webhook_id) REFERENCES webhooks(id)\n  ON DELETE CASCADE\n  ON UPDATE CASCADE\n);\n\nCREATE TABLE IF NOT EXISTS webhook_deliveries (\n  id TEXT PRIMARY KEY,\n  webhook_id INTEGER NOT NULL,\n  event INTEGER NOT NULL,\n  request_url TEXT NOT NULL,\n  request_method TEXT NOT NULL,\n  request_error TEXT,\n  request_headers TEXT NOT NULL,\n  request_body TEXT NOT NULL,\n  response_status INTEGER NOT NULL,\n  response_headers TEXT NOT NULL,\n  response_body TEXT NOT NULL,\n  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  CONSTRAINT webhook_id_fk\n  FOREIGN KEY(webhook_id) REFERENCES webhooks(id)\n  ON DELETE CASCADE\n  ON UPDATE CASCADE\n);\n"
  },
  {
    "path": "pkg/db/migrate/0003_migrate_lfs_objects.go",
    "content": "package migrate\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strconv\"\n\n\t\"charm.land/log/v2\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db/models\"\n)\n\nconst (\n\tmigrateLfsObjectsName    = \"migrate_lfs_objects\"\n\tmigrateLfsObjectsVersion = 3\n)\n\n// Correct LFS objects relative path.\n// From OID[:2]/OID[2:4]/OID[4:] to OID[:2]/OID[2:4]/OID\n// See: https://github.com/git-lfs/git-lfs/blob/main/docs/spec.md#intercepting-git\nvar migrateLfsObjects = Migration{\n\tName:    migrateLfsObjectsName,\n\tVersion: migrateLfsObjectsVersion,\n\tMigrate: func(ctx context.Context, tx *db.Tx) error {\n\t\tcfg := config.FromContext(ctx)\n\t\tlogger := log.FromContext(ctx).WithPrefix(\"migrate_lfs_objects\")\n\n\t\tvar repoIDs []int64\n\t\tif err := tx.Select(&repoIDs, \"SELECT id FROM repos\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tfor _, r := range repoIDs {\n\t\t\tvar objs []models.LFSObject\n\t\t\tif err := tx.Select(&objs, \"SELECT * FROM lfs_objects WHERE repo_id = ?\", r); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tobjsp := filepath.Join(cfg.DataPath, \"lfs\", strconv.FormatInt(r, 10), \"objects\")\n\t\t\tfor _, obj := range objs {\n\t\t\t\toldpath := filepath.Join(objsp, badRelativePath(obj.Oid))\n\t\t\t\tnewpath := filepath.Join(objsp, goodRelativePath(obj.Oid))\n\t\t\t\tif _, err := os.Stat(oldpath); err == nil {\n\t\t\t\t\tif err := os.Rename(oldpath, newpath); err != nil {\n\t\t\t\t\t\tlogger.Error(\"rename lfs object\", \"oldpath\", oldpath, \"newpath\", newpath, \"err\", err)\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t},\n\tRollback: func(context.Context, *db.Tx) error {\n\t\treturn nil\n\t},\n}\n\nfunc goodRelativePath(oid string) string {\n\tif len(oid) < 5 {\n\t\treturn oid\n\t}\n\treturn filepath.Join(oid[:2], oid[2:4], oid)\n}\n\nfunc badRelativePath(oid string) string {\n\tif len(oid) < 5 {\n\t\treturn oid\n\t}\n\treturn filepath.Join(oid[:2], oid[2:4], oid[4:])\n}\n"
  },
  {
    "path": "pkg/db/migrate/migrate.go",
    "content": "package migrate\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\n\t\"charm.land/log/v2\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n)\n\n// MigrateFunc is a function that executes a migration.\ntype MigrateFunc func(ctx context.Context, tx *db.Tx) error //nolint:revive\n\n// Migration is a struct that contains the name of the migration and the\n// function to execute it.\ntype Migration struct {\n\tVersion  int64\n\tName     string\n\tMigrate  MigrateFunc\n\tRollback MigrateFunc\n}\n\n// Migrations is a database model to store migrations.\ntype Migrations struct {\n\tID      int64  `db:\"id\"`\n\tName    string `db:\"name\"`\n\tVersion int64  `db:\"version\"`\n}\n\nfunc (Migrations) schema(driverName string) string {\n\tswitch driverName {\n\tcase \"sqlite3\", \"sqlite\":\n\t\treturn `CREATE TABLE IF NOT EXISTS migrations (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tname TEXT NOT NULL,\n\t\t\t\tversion INTEGER NOT NULL UNIQUE\n\t\t\t);\n\t\t`\n\tcase \"postgres\":\n\t\treturn `CREATE TABLE IF NOT EXISTS migrations (\n\t\t\tid SERIAL PRIMARY KEY,\n\t\t\tname TEXT NOT NULL,\n\t\t\tversion INTEGER NOT NULL UNIQUE\n\t\t);\n\t`\n\tcase \"mysql\":\n\t\treturn `CREATE TABLE IF NOT EXISTS migrations (\n\t\t\tid INT NOT NULL AUTO_INCREMENT,\n\t\t\tname TEXT NOT NULL,\n\t\t\tversion INT NOT NULL,\n\t\t\tUNIQUE (version),\n\t\t\tPRIMARY KEY (id)\n\t\t);\n\t`\n\tdefault:\n\t\tpanic(\"unknown driver\")\n\t}\n}\n\n// Migrate runs the migrations.\nfunc Migrate(ctx context.Context, dbx *db.DB) error {\n\tlogger := log.FromContext(ctx).WithPrefix(\"migrate\")\n\treturn dbx.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\tif !hasTable(tx, \"migrations\") {\n\t\t\tif _, err := tx.ExecContext(ctx, Migrations{}.schema(tx.DriverName())); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\tvar migrs Migrations\n\t\tif err := tx.Get(&migrs, tx.Rebind(\"SELECT * FROM migrations ORDER BY version DESC LIMIT 1\")); err != nil {\n\t\t\tif !errors.Is(err, sql.ErrNoRows) {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\tfor _, m := range migrations {\n\t\t\tif m.Version <= migrs.Version {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tlogger.Infof(\"running migration %d. %s\", m.Version, m.Name)\n\t\t\tif err := m.Migrate(ctx, tx); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif _, err := tx.ExecContext(ctx, tx.Rebind(\"INSERT INTO migrations (name, version) VALUES (?, ?)\"), m.Name, m.Version); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\n// Rollback rolls back a migration.\nfunc Rollback(ctx context.Context, dbx *db.DB) error {\n\tlogger := log.FromContext(ctx).WithPrefix(\"migrate\")\n\treturn dbx.TransactionContext(ctx, func(tx *db.Tx) error {\n\t\tvar migrs Migrations\n\t\tif err := tx.Get(&migrs, tx.Rebind(\"SELECT * FROM migrations ORDER BY version DESC LIMIT 1\")); err != nil {\n\t\t\tif !errors.Is(err, sql.ErrNoRows) {\n\t\t\t\treturn fmt.Errorf(\"there are no migrations to rollback: %w\", err)\n\t\t\t}\n\t\t}\n\n\t\tif migrs.Version == 0 || len(migrations) < int(migrs.Version) {\n\t\t\treturn fmt.Errorf(\"there are no migrations to rollback\")\n\t\t}\n\n\t\tm := migrations[migrs.Version-1]\n\t\tlogger.Infof(\"rolling back migration %d. %s\", m.Version, m.Name)\n\t\tif err := m.Rollback(ctx, tx); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err := tx.ExecContext(ctx, tx.Rebind(\"DELETE FROM migrations WHERE version = ?\"), migrs.Version); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc hasTable(tx *db.Tx, tableName string) bool {\n\tvar query string\n\tswitch tx.DriverName() {\n\tcase \"sqlite3\", \"sqlite\":\n\t\tquery = \"SELECT name FROM sqlite_master WHERE type='table' AND name=?\"\n\tcase \"postgres\":\n\t\tfallthrough\n\tcase \"mysql\":\n\t\tquery = \"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = ?\"\n\t}\n\n\tquery = tx.Rebind(query)\n\tvar name string\n\terr := tx.Get(&name, query, tableName)\n\treturn err == nil\n}\n"
  },
  {
    "path": "pkg/db/migrate/migrate_test.go",
    "content": "package migrate\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db/internal/test\"\n)\n\nfunc TestMigrate(t *testing.T) {\n\t// XXX: we need a config.Config in the context for the migrations to run\n\t// properly. Some migrations depend on the config being present.\n\tctx := config.WithContext(context.TODO(), config.DefaultConfig())\n\tdbx, err := test.OpenSqlite(ctx, t)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := Migrate(ctx, dbx); err != nil {\n\t\tt.Errorf(\"Migrate() => %v, want nil error\", err)\n\t}\n}\n"
  },
  {
    "path": "pkg/db/migrate/migrations.go",
    "content": "package migrate\n\nimport (\n\t\"context\"\n\t\"embed\"\n\t\"fmt\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n)\n\n//go:embed *.sql\nvar sqls embed.FS\n\n// Keep this in order of execution, oldest to newest.\nvar migrations = []Migration{\n\tcreateTables,\n\twebhooks,\n\tmigrateLfsObjects,\n}\n\nfunc execMigration(ctx context.Context, tx *db.Tx, version int, name string, down bool) error {\n\tdirection := \"up\"\n\tif down {\n\t\tdirection = \"down\"\n\t}\n\n\tdriverName := tx.DriverName()\n\tif driverName == \"sqlite3\" {\n\t\tdriverName = \"sqlite\"\n\t}\n\n\tfn := fmt.Sprintf(\"%04d_%s_%s.%s.sql\", version, toSnakeCase(name), driverName, direction)\n\tsqlstr, err := sqls.ReadFile(fn)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif _, err := tx.ExecContext(ctx, string(sqlstr)); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc migrateUp(ctx context.Context, tx *db.Tx, version int, name string) error {\n\treturn execMigration(ctx, tx, version, name, false)\n}\n\nfunc migrateDown(ctx context.Context, tx *db.Tx, version int, name string) error {\n\treturn execMigration(ctx, tx, version, name, true)\n}\n\nvar (\n\tmatchFirstCap = regexp.MustCompile(\"(.)([A-Z][a-z]+)\")\n\tmatchAllCap   = regexp.MustCompile(\"([a-z0-9])([A-Z])\")\n)\n\nfunc toSnakeCase(str string) string {\n\tstr = strings.ReplaceAll(str, \"-\", \"_\")\n\tstr = strings.ReplaceAll(str, \" \", \"_\")\n\tsnake := matchFirstCap.ReplaceAllString(str, \"${1}_${2}\")\n\tsnake = matchAllCap.ReplaceAllString(snake, \"${1}_${2}\")\n\treturn strings.ToLower(snake)\n}\n"
  },
  {
    "path": "pkg/db/models/access_token.go",
    "content": "package models\n\nimport (\n\t\"database/sql\"\n\t\"time\"\n)\n\n// AccessToken represents an access token.\ntype AccessToken struct {\n\tID        int64        `db:\"id\"`\n\tName      string       `db:\"name\"`\n\tUserID    int64        `db:\"user_id\"`\n\tToken     string       `db:\"token\"`\n\tExpiresAt sql.NullTime `db:\"expires_at\"`\n\tCreatedAt time.Time    `db:\"created_at\"`\n\tUpdatedAt time.Time    `db:\"updated_at\"`\n}\n"
  },
  {
    "path": "pkg/db/models/collab.go",
    "content": "package models\n\nimport (\n\t\"time\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/access\"\n)\n\n// Collab represents a repository collaborator.\ntype Collab struct {\n\tID          int64              `db:\"id\"`\n\tRepoID      int64              `db:\"repo_id\"`\n\tUserID      int64              `db:\"user_id\"`\n\tAccessLevel access.AccessLevel `db:\"access_level\"`\n\tCreatedAt   time.Time          `db:\"created_at\"`\n\tUpdatedAt   time.Time          `db:\"updated_at\"`\n}\n"
  },
  {
    "path": "pkg/db/models/lfs.go",
    "content": "package models\n\nimport \"time\"\n\n// LFSObject is a Git LFS object.\ntype LFSObject struct {\n\tID        int64     `db:\"id\"`\n\tOid       string    `db:\"oid\"`\n\tSize      int64     `db:\"size\"`\n\tRepoID    int64     `db:\"repo_id\"`\n\tCreatedAt time.Time `db:\"created_at\"`\n\tUpdatedAt time.Time `db:\"updated_at\"`\n}\n\n// LFSLock is a Git LFS lock.\ntype LFSLock struct {\n\tID        int64     `db:\"id\"`\n\tPath      string    `db:\"path\"`\n\tUserID    int64     `db:\"user_id\"`\n\tRepoID    int64     `db:\"repo_id\"`\n\tRefname   string    `db:\"refname\"`\n\tCreatedAt time.Time `db:\"created_at\"`\n\tUpdatedAt time.Time `db:\"updated_at\"`\n}\n"
  },
  {
    "path": "pkg/db/models/public_key.go",
    "content": "package models\n\n// PublicKey represents a public key.\ntype PublicKey struct {\n\tID        int64  `db:\"id\"`\n\tUserID    int64  `db:\"user_id\"`\n\tPublicKey string `db:\"public_key\"`\n\tCreatedAt string `db:\"created_at\"`\n\tUpdatedAt string `db:\"updated_at\"`\n}\n"
  },
  {
    "path": "pkg/db/models/repo.go",
    "content": "package models\n\nimport (\n\t\"database/sql\"\n\t\"time\"\n)\n\n// Repo is a database model for a repository.\ntype Repo struct {\n\tID          int64         `db:\"id\"`\n\tName        string        `db:\"name\"`\n\tProjectName string        `db:\"project_name\"`\n\tDescription string        `db:\"description\"`\n\tPrivate     bool          `db:\"private\"`\n\tMirror      bool          `db:\"mirror\"`\n\tHidden      bool          `db:\"hidden\"`\n\tUserID      sql.NullInt64 `db:\"user_id\"`\n\tCreatedAt   time.Time     `db:\"created_at\"`\n\tUpdatedAt   time.Time     `db:\"updated_at\"`\n}\n"
  },
  {
    "path": "pkg/db/models/settings.go",
    "content": "package models\n\n// Settings represents a settings record.\ntype Settings struct {\n\tID        int64  `db:\"id\"`\n\tKey       string `db:\"key\"`\n\tValue     string `db:\"value\"`\n\tCreatedAt string `db:\"created_at\"`\n\tUpdatedAt string `db:\"updated_at\"`\n}\n"
  },
  {
    "path": "pkg/db/models/user.go",
    "content": "package models\n\nimport (\n\t\"database/sql\"\n\t\"time\"\n)\n\n// User represents a user.\ntype User struct {\n\tID        int64          `db:\"id\"`\n\tUsername  string         `db:\"username\"`\n\tAdmin     bool           `db:\"admin\"`\n\tPassword  sql.NullString `db:\"password\"`\n\tCreatedAt time.Time      `db:\"created_at\"`\n\tUpdatedAt time.Time      `db:\"updated_at\"`\n}\n"
  },
  {
    "path": "pkg/db/models/webhook.go",
    "content": "package models\n\nimport (\n\t\"database/sql\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n)\n\n// Webhook is a repository webhook.\ntype Webhook struct {\n\tID          int64     `db:\"id\"`\n\tRepoID      int64     `db:\"repo_id\"`\n\tURL         string    `db:\"url\"`\n\tSecret      string    `db:\"secret\"`\n\tContentType int       `db:\"content_type\"`\n\tActive      bool      `db:\"active\"`\n\tCreatedAt   time.Time `db:\"created_at\"`\n\tUpdatedAt   time.Time `db:\"updated_at\"`\n}\n\n// WebhookEvent is a webhook event.\ntype WebhookEvent struct {\n\tID        int64     `db:\"id\"`\n\tWebhookID int64     `db:\"webhook_id\"`\n\tEvent     int       `db:\"event\"`\n\tCreatedAt time.Time `db:\"created_at\"`\n}\n\n// WebhookDelivery is a webhook delivery.\ntype WebhookDelivery struct {\n\tID              uuid.UUID      `db:\"id\"`\n\tWebhookID       int64          `db:\"webhook_id\"`\n\tEvent           int            `db:\"event\"`\n\tRequestURL      string         `db:\"request_url\"`\n\tRequestMethod   string         `db:\"request_method\"`\n\tRequestError    sql.NullString `db:\"request_error\"`\n\tRequestHeaders  string         `db:\"request_headers\"`\n\tRequestBody     string         `db:\"request_body\"`\n\tResponseStatus  int            `db:\"response_status\"`\n\tResponseHeaders string         `db:\"response_headers\"`\n\tResponseBody    string         `db:\"response_body\"`\n\tCreatedAt       time.Time      `db:\"created_at\"`\n}\n"
  },
  {
    "path": "pkg/git/errors.go",
    "content": "package git\n\nimport \"errors\"\n\nvar (\n\t// ErrNotAuthed represents unauthorized access.\n\tErrNotAuthed = errors.New(\"you are not authorized to do this\")\n\n\t// ErrSystemMalfunction represents a general system error returned to clients.\n\tErrSystemMalfunction = errors.New(\"something went wrong\")\n\n\t// ErrInvalidRepo represents an attempt to access a non-existent repo.\n\tErrInvalidRepo = errors.New(\"invalid repo\")\n\n\t// ErrInvalidRequest represents an invalid request.\n\tErrInvalidRequest = errors.New(\"invalid request\")\n\n\t// ErrMaxConnections represents a maximum connection limit being reached.\n\tErrMaxConnections = errors.New(\"too many connections, try again later\")\n\n\t// ErrTimeout is returned when the maximum read timeout is exceeded.\n\tErrTimeout = errors.New(\"I/O timeout reached\")\n)\n"
  },
  {
    "path": "pkg/git/git.go",
    "content": "package git\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"charm.land/log/v2\"\n\tgitm \"github.com/aymanbagabas/git-module\"\n\t\"github.com/charmbracelet/soft-serve/git\"\n\t\"github.com/go-git/go-git/v5/plumbing/format/pktline\"\n)\n\n// ErrNoBranches is returned when a repo has no branches.\nvar ErrNoBranches = errors.New(\"no branches found\")\n\n// WritePktline encodes and writes a pktline to the given writer.\nfunc WritePktline(w io.Writer, v ...interface{}) error {\n\tmsg := fmt.Sprintln(v...)\n\tpkt := pktline.NewEncoder(w)\n\tif err := pkt.EncodeString(msg); err != nil {\n\t\treturn fmt.Errorf(\"git: error writing pkt-line message: %w\", err)\n\t}\n\tif err := pkt.Flush(); err != nil {\n\t\treturn fmt.Errorf(\"git: error flushing pkt-line message: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// WritePktlineErr writes an error pktline to the given writer.\nfunc WritePktlineErr(w io.Writer, err error) error {\n\treturn WritePktline(w, \"ERR\", err.Error())\n}\n\n// EnsureWithin ensures the given repo is within the repos directory.\nfunc EnsureWithin(reposDir string, repo string) error {\n\trepoDir := filepath.Join(reposDir, repo)\n\tabsRepos, err := filepath.Abs(reposDir)\n\tif err != nil {\n\t\tlog.Debugf(\"failed to get absolute path for repo: %s\", err)\n\t\treturn ErrSystemMalfunction\n\t}\n\tabsRepo, err := filepath.Abs(repoDir)\n\tif err != nil {\n\t\tlog.Debugf(\"failed to get absolute path for repos: %s\", err)\n\t\treturn ErrSystemMalfunction\n\t}\n\n\t// ensure the repo is within the repos directory\n\tif !strings.HasPrefix(absRepo, absRepos) {\n\t\tlog.Debugf(\"repo path is outside of repos directory: %s\", absRepo)\n\t\treturn ErrInvalidRepo\n\t}\n\n\treturn nil\n}\n\n// EnsureDefaultBranch ensures the repo has a default branch.\n// It will prefer choosing \"main\" or \"master\" if available.\nfunc EnsureDefaultBranch(ctx context.Context, repoPath string) error {\n\tr, err := git.Open(repoPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbrs, err := r.Branches()\n\tif len(brs) == 0 {\n\t\treturn ErrNoBranches\n\t}\n\tif err != nil {\n\t\treturn err\n\t}\n\t// Rename the default branch to the first branch available\n\t_, err = r.HEAD()\n\tif err == git.ErrReferenceNotExist {\n\t\tbranch := brs[0]\n\t\t// Prefer \"main\" or \"master\" as the default branch\n\t\tfor _, b := range brs {\n\t\t\tif b == \"main\" || b == \"master\" {\n\t\t\t\tbranch = b\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif _, err := r.SymbolicRef(git.HEAD, git.RefsHeads+branch, gitm.SymbolicRefOptions{\n\t\t\tCommandOptions: gitm.CommandOptions{\n\t\t\t\tContext: ctx,\n\t\t\t},\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif err != nil && err != git.ErrReferenceNotExist {\n\t\treturn err\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/git/git_test.go",
    "content": "package git\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"testing\"\n\n\t\"github.com/charmbracelet/soft-serve/git\"\n)\n\nfunc TestPktline(t *testing.T) {\n\tcases := []struct {\n\t\tname string\n\t\tin   []byte\n\t\terr  error\n\t\tout  []byte\n\t}{\n\t\t{\n\t\t\tname: \"empty\",\n\t\t\tin:   []byte{},\n\t\t\tout:  []byte(\"0005\\n0000\"),\n\t\t},\n\t\t{\n\t\t\tname: \"simple\",\n\t\t\tin:   []byte(\"hello\"),\n\t\t\tout:  []byte(\"000ahello\\n0000\"),\n\t\t},\n\t\t{\n\t\t\tname: \"newline\",\n\t\t\tin:   []byte(\"hello\\n\"),\n\t\t\tout:  []byte(\"000bhello\\n\\n0000\"),\n\t\t},\n\t\t{\n\t\t\tname: \"error\",\n\t\t\terr:  fmt.Errorf(\"foobar\"),\n\t\t\tout:  []byte(\"000fERR foobar\\n0000\"),\n\t\t},\n\t}\n\n\tfor _, c := range cases {\n\t\tt.Run(c.name, func(t *testing.T) {\n\t\t\tvar out bytes.Buffer\n\t\t\tif c.err == nil {\n\t\t\t\tif err := WritePktline(&out, string(c.in)); err != nil {\n\t\t\t\t\tt.Fatal(err)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err := WritePktlineErr(&out, c.err); err != nil {\n\t\t\t\t\tt.Fatal(err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif !bytes.Equal(out.Bytes(), c.out) {\n\t\t\t\tt.Errorf(\"expected %q, got %q\", c.out, out.Bytes())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEnsureWithinBad(t *testing.T) {\n\ttmp := t.TempDir()\n\tfor _, f := range []string{\n\t\t\"..\",\n\t\t\"../../../\",\n\t} {\n\t\tif err := EnsureWithin(tmp, f); err == nil {\n\t\t\tt.Errorf(\"EnsureWithin(%q, %q) => nil, want non-nil error\", tmp, f)\n\t\t}\n\t}\n}\n\nfunc TestEnsureWithinGood(t *testing.T) {\n\ttmp := t.TempDir()\n\tfor _, f := range []string{\n\t\ttmp,\n\t\ttmp + \"/foo\",\n\t\ttmp + \"/foo/bar\",\n\t} {\n\t\tif err := EnsureWithin(tmp, f); err != nil {\n\t\t\tt.Errorf(\"EnsureWithin(%q, %q) => %v, want nil error\", tmp, f, err)\n\t\t}\n\t}\n}\n\nfunc TestEnsureDefaultBranchEmpty(t *testing.T) {\n\ttmp := t.TempDir()\n\tr, err := git.Init(tmp, false)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif err := EnsureDefaultBranch(context.TODO(), r.Path); !errors.Is(err, ErrNoBranches) {\n\t\tt.Errorf(\"EnsureDefaultBranch(%q) => %v, want ErrNoBranches\", tmp, err)\n\t}\n}\n"
  },
  {
    "path": "pkg/git/lfs.go",
    "content": "package git\n\nimport (\n\t\"context\"\n\t\"crypto/rand\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"path\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"charm.land/log/v2\"\n\t\"github.com/charmbracelet/git-lfs-transfer/transfer\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db/models\"\n\t\"github.com/charmbracelet/soft-serve/pkg/lfs\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/storage\"\n\t\"github.com/charmbracelet/soft-serve/pkg/store\"\n)\n\n// lfsTransfer implements transfer.Backend.\ntype lfsTransfer struct {\n\tctx     context.Context\n\tcfg     *config.Config\n\tdbx     *db.DB\n\tstore   store.Store\n\tlogger  *log.Logger\n\tstorage storage.Storage\n\trepo    proto.Repository\n}\n\nvar _ transfer.Backend = &lfsTransfer{}\n\n// LFSTransfer is a Git LFS transfer service handler.\n// ctx is expected to have proto.User, *backend.Backend, *log.Logger,\n// *config.Config, *db.DB, and store.Store.\n// The first arg in cmd.Args should be the repo path.\n// The second arg in cmd.Args should be the LFS operation (download or upload).\nfunc LFSTransfer(ctx context.Context, cmd ServiceCommand) error {\n\tif len(cmd.Args) < 2 {\n\t\treturn errors.New(\"missing args\")\n\t}\n\n\top := cmd.Args[1]\n\tif op != lfs.OperationDownload && op != lfs.OperationUpload {\n\t\treturn errors.New(\"invalid operation\")\n\t}\n\n\tlogger := log.FromContext(ctx).WithPrefix(\"lfs-transfer\")\n\thandler := transfer.NewPktline(cmd.Stdin, cmd.Stdout, &lfsLogger{logger})\n\trepo := proto.RepositoryFromContext(ctx)\n\tif repo == nil {\n\t\tlogger.Error(\"no repository in context\")\n\t\treturn proto.ErrRepoNotFound\n\t}\n\n\t// Advertise capabilities.\n\tfor _, cap := range transfer.Capabilities {\n\t\tif err := handler.WritePacketText(cap); err != nil {\n\t\t\tlogger.Errorf(\"error sending capability: %s: %v\", cap, err)\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := handler.WriteFlush(); err != nil {\n\t\tlogger.Error(\"error sending flush\", \"err\", err)\n\t\treturn err\n\t}\n\n\trepoID := strconv.FormatInt(repo.ID(), 10)\n\tcfg := config.FromContext(ctx)\n\tprocessor := transfer.NewProcessor(handler, &lfsTransfer{\n\t\tctx:     ctx,\n\t\tcfg:     cfg,\n\t\tdbx:     db.FromContext(ctx),\n\t\tstore:   store.FromContext(ctx),\n\t\tlogger:  logger,\n\t\tstorage: storage.NewLocalStorage(filepath.Join(cfg.DataPath, \"lfs\", repoID)),\n\t\trepo:    repo,\n\t}, &lfsLogger{logger})\n\n\treturn processor.ProcessCommands(op)\n}\n\n// Batch implements transfer.Backend.\nfunc (t *lfsTransfer) Batch(_ string, pointers []transfer.BatchItem, _ transfer.Args) ([]transfer.BatchItem, error) {\n\tfor i := range pointers {\n\t\tobj, err := t.store.GetLFSObjectByOid(t.ctx, t.dbx, t.repo.ID(), pointers[i].Oid)\n\t\tif err != nil && !errors.Is(err, db.ErrRecordNotFound) {\n\t\t\treturn pointers, db.WrapError(err)\n\t\t}\n\n\t\tpointers[i].Present, err = t.storage.Exists(path.Join(\"objects\", pointers[i].RelativePath()))\n\t\tif err != nil {\n\t\t\treturn pointers, err\n\t\t}\n\n\t\tif pointers[i].Present && obj.ID == 0 {\n\t\t\tif err := t.store.CreateLFSObject(t.ctx, t.dbx, t.repo.ID(), pointers[i].Oid, pointers[i].Size); err != nil {\n\t\t\t\treturn pointers, db.WrapError(err)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn pointers, nil\n}\n\n// Download implements transfer.Backend.\nfunc (t *lfsTransfer) Download(oid string, _ transfer.Args) (io.ReadCloser, int64, error) {\n\tcfg := config.FromContext(t.ctx)\n\trepoID := strconv.FormatInt(t.repo.ID(), 10)\n\tstrg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, \"lfs\", repoID))\n\tpointer := transfer.Pointer{Oid: oid}\n\tobj, err := strg.Open(path.Join(\"objects\", pointer.RelativePath()))\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\tstat, err := obj.Stat()\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\treturn obj, stat.Size(), nil\n}\n\n// Upload implements transfer.Backend.\nfunc (t *lfsTransfer) Upload(oid string, size int64, r io.Reader, _ transfer.Args) error {\n\tif r == nil {\n\t\treturn fmt.Errorf(\"no reader: %w\", transfer.ErrMissingData)\n\t}\n\n\ttempDir := \"incomplete\"\n\trandBytes := make([]byte, 12)\n\tif _, err := rand.Read(randBytes); err != nil {\n\t\treturn err\n\t}\n\n\ttempName := fmt.Sprintf(\"%s%x\", oid, randBytes)\n\ttempName = path.Join(tempDir, tempName)\n\n\twritten, err := t.storage.Put(tempName, r)\n\tif err != nil {\n\t\tt.logger.Errorf(\"error putting object: %v\", err)\n\t\treturn err\n\t}\n\n\tobj, err := t.storage.Open(tempName)\n\tif err != nil {\n\t\tt.logger.Errorf(\"error opening object: %v\", err)\n\t\treturn err\n\t}\n\n\tpointer := transfer.Pointer{\n\t\tOid: oid,\n\t}\n\tif size > 0 {\n\t\tpointer.Size = size\n\t} else {\n\t\tpointer.Size = written\n\t}\n\n\tif err := t.store.CreateLFSObject(t.ctx, t.dbx, t.repo.ID(), pointer.Oid, pointer.Size); err != nil {\n\t\treturn db.WrapError(err)\n\t}\n\n\texpectedPath := path.Join(\"objects\", pointer.RelativePath())\n\tif err := t.storage.Rename(obj.Name(), expectedPath); err != nil {\n\t\tt.logger.Errorf(\"error renaming object: %v\", err)\n\t\t_ = t.store.DeleteLFSObjectByOid(t.ctx, t.dbx, t.repo.ID(), pointer.Oid)\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// Verify implements transfer.Backend.\nfunc (t *lfsTransfer) Verify(oid string, size int64, _ transfer.Args) (transfer.Status, error) {\n\tobj, err := t.store.GetLFSObjectByOid(t.ctx, t.dbx, t.repo.ID(), oid)\n\tif err != nil {\n\t\tif errors.Is(err, db.ErrRecordNotFound) {\n\t\t\treturn transfer.NewStatus(transfer.StatusNotFound, \"object not found\"), nil\n\t\t}\n\t\tt.logger.Errorf(\"error getting object: %v\", err)\n\t\treturn nil, err\n\t}\n\n\tif obj.Size != size {\n\t\tt.logger.Errorf(\"size mismatch: %d != %d\", obj.Size, size)\n\t\treturn transfer.NewStatus(transfer.StatusConflict, \"size mismatch\"), nil\n\t}\n\n\treturn transfer.SuccessStatus(), nil\n}\n\ntype lfsLockBackend struct {\n\t*lfsTransfer\n\targs map[string]string\n\tuser proto.User\n}\n\nvar _ transfer.LockBackend = (*lfsLockBackend)(nil)\n\n// LockBackend implements transfer.Backend.\nfunc (t *lfsTransfer) LockBackend(args transfer.Args) transfer.LockBackend {\n\tuser := proto.UserFromContext(t.ctx)\n\tif user == nil {\n\t\tt.logger.Errorf(\"no user in context while creating lock backend, repo %s\", t.repo.Name())\n\t\treturn nil\n\t}\n\n\treturn &lfsLockBackend{t, args, user}\n}\n\n// Create implements transfer.LockBackend.\nfunc (l *lfsLockBackend) Create(path string, refname string) (transfer.Lock, error) {\n\tvar lock LFSLock\n\tif err := l.dbx.TransactionContext(l.ctx, func(tx *db.Tx) error {\n\t\tif err := l.store.CreateLFSLockForUser(l.ctx, tx, l.repo.ID(), l.user.ID(), path, refname); err != nil {\n\t\t\treturn db.WrapError(err)\n\t\t}\n\n\t\tvar err error\n\t\tlock.lock, err = l.store.GetLFSLockForUserPath(l.ctx, tx, l.repo.ID(), l.user.ID(), path)\n\t\tif err != nil {\n\t\t\treturn db.WrapError(err)\n\t\t}\n\n\t\tlock.owner, err = l.store.GetUserByID(l.ctx, tx, lock.lock.UserID)\n\t\treturn db.WrapError(err)\n\t}); err != nil {\n\t\t// Return conflict (409) if the lock already exists.\n\t\tif errors.Is(err, db.ErrDuplicateKey) {\n\t\t\treturn nil, transfer.ErrConflict\n\t\t}\n\t\tl.logger.Errorf(\"error creating lock: %v\", err)\n\t\treturn nil, err\n\t}\n\n\tlock.backend = l\n\n\treturn &lock, nil\n}\n\n// FromID implements transfer.LockBackend.\nfunc (l *lfsLockBackend) FromID(id string) (transfer.Lock, error) {\n\tvar lock LFSLock\n\tiid, err := strconv.ParseInt(id, 10, 64)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := l.dbx.TransactionContext(l.ctx, func(tx *db.Tx) error {\n\t\tvar err error\n\t\tlock.lock, err = l.store.GetLFSLockForUserByID(l.ctx, tx, l.repo.ID(), l.user.ID(), iid)\n\t\tif err != nil {\n\t\t\treturn db.WrapError(err)\n\t\t}\n\n\t\tlock.owner, err = l.store.GetUserByID(l.ctx, tx, lock.lock.UserID)\n\t\treturn db.WrapError(err)\n\t}); err != nil {\n\t\tif errors.Is(err, db.ErrRecordNotFound) {\n\t\t\treturn nil, transfer.ErrNotFound\n\t\t}\n\t\tl.logger.Errorf(\"error getting lock: %v\", err)\n\t\treturn nil, err\n\t}\n\n\tlock.backend = l\n\n\treturn &lock, nil\n}\n\n// FromPath implements transfer.LockBackend.\nfunc (l *lfsLockBackend) FromPath(path string) (transfer.Lock, error) {\n\tvar lock LFSLock\n\n\tif err := l.dbx.TransactionContext(l.ctx, func(tx *db.Tx) error {\n\t\tvar err error\n\t\tlock.lock, err = l.store.GetLFSLockForUserPath(l.ctx, tx, l.repo.ID(), l.user.ID(), path)\n\t\tif err != nil {\n\t\t\treturn db.WrapError(err)\n\t\t}\n\n\t\tlock.owner, err = l.store.GetUserByID(l.ctx, tx, lock.lock.UserID)\n\t\treturn db.WrapError(err)\n\t}); err != nil {\n\t\tif errors.Is(err, db.ErrRecordNotFound) {\n\t\t\treturn nil, transfer.ErrNotFound\n\t\t}\n\t\tl.logger.Errorf(\"error getting lock: %v\", err)\n\t\treturn nil, err\n\t}\n\n\tlock.backend = l\n\n\treturn &lock, nil\n}\n\n// Range implements transfer.LockBackend.\nfunc (l *lfsLockBackend) Range(cursor string, limit int, fn func(transfer.Lock) error) (string, error) {\n\tvar nextCursor string\n\tvar locks []*LFSLock\n\n\tpage, _ := strconv.Atoi(cursor)\n\tif page <= 0 {\n\t\tpage = 1\n\t}\n\n\tif limit <= 0 {\n\t\tlimit = lfs.DefaultLocksLimit\n\t} else if limit > 100 {\n\t\tlimit = 100\n\t}\n\n\tif err := l.dbx.TransactionContext(l.ctx, func(tx *db.Tx) error {\n\t\tl.logger.Debug(\"getting locks\", \"limit\", limit, \"page\", page)\n\t\tmlocks, err := l.store.GetLFSLocks(l.ctx, tx, l.repo.ID(), page, limit)\n\t\tif err != nil {\n\t\t\treturn db.WrapError(err)\n\t\t}\n\n\t\tif len(mlocks) == limit {\n\t\t\tnextCursor = strconv.Itoa(page + 1)\n\t\t}\n\n\t\tusers := make(map[int64]models.User, 0)\n\t\tfor _, mlock := range mlocks {\n\t\t\towner, ok := users[mlock.UserID]\n\t\t\tif !ok {\n\t\t\t\towner, err = l.store.GetUserByID(l.ctx, tx, mlock.UserID)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn db.WrapError(err)\n\t\t\t\t}\n\n\t\t\t\tusers[mlock.UserID] = owner\n\t\t\t}\n\n\t\t\tlocks = append(locks, &LFSLock{lock: mlock, owner: owner, backend: l})\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn \"\", err\n\t}\n\n\tfor _, lock := range locks {\n\t\tif err := fn(lock); err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t}\n\n\treturn nextCursor, nil\n}\n\n// Unlock implements transfer.LockBackend.\nfunc (l *lfsLockBackend) Unlock(lock transfer.Lock) error {\n\tid, err := strconv.ParseInt(lock.ID(), 10, 64)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = l.dbx.TransactionContext(l.ctx, func(tx *db.Tx) error {\n\t\treturn db.WrapError(\n\t\t\tl.store.DeleteLFSLockForUserByID(l.ctx, tx, l.repo.ID(), l.user.ID(), id),\n\t\t)\n\t})\n\tif err != nil {\n\t\tif errors.Is(err, db.ErrRecordNotFound) {\n\t\t\treturn transfer.ErrNotFound\n\t\t}\n\t\tl.logger.Error(\"error unlocking lock\", \"err\", err)\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// LFSLock is a Git LFS lock object.\n// It implements transfer.Lock.\ntype LFSLock struct {\n\tlock    models.LFSLock\n\towner   models.User\n\tbackend *lfsLockBackend\n}\n\nvar _ transfer.Lock = (*LFSLock)(nil)\n\n// AsArguments implements transfer.Lock.\nfunc (l *LFSLock) AsArguments() []string {\n\treturn []string{\n\t\tfmt.Sprintf(\"id=%s\", l.ID()),\n\t\tfmt.Sprintf(\"path=%s\", l.Path()),\n\t\tfmt.Sprintf(\"locked-at=%s\", l.FormattedTimestamp()),\n\t\tfmt.Sprintf(\"ownername=%s\", l.OwnerName()),\n\t}\n}\n\n// AsLockSpec implements transfer.Lock.\nfunc (l *LFSLock) AsLockSpec(ownerID bool) ([]string, error) {\n\tid := l.ID()\n\tspec := []string{\n\t\tfmt.Sprintf(\"lock %s\", id),\n\t\tfmt.Sprintf(\"path %s %s\", id, l.Path()),\n\t\tfmt.Sprintf(\"locked-at %s %s\", id, l.FormattedTimestamp()),\n\t\tfmt.Sprintf(\"ownername %s %s\", id, l.OwnerName()),\n\t}\n\n\tif ownerID {\n\t\twho := \"theirs\"\n\t\tif l.lock.UserID == l.owner.ID {\n\t\t\twho = \"ours\"\n\t\t}\n\n\t\tspec = append(spec, fmt.Sprintf(\"owner %s %s\", id, who))\n\t}\n\n\treturn spec, nil\n}\n\n// FormattedTimestamp implements transfer.Lock.\nfunc (l *LFSLock) FormattedTimestamp() string {\n\treturn l.lock.CreatedAt.Format(time.RFC3339)\n}\n\n// ID implements transfer.Lock.\nfunc (l *LFSLock) ID() string {\n\treturn strconv.FormatInt(l.lock.ID, 10)\n}\n\n// OwnerName implements transfer.Lock.\nfunc (l *LFSLock) OwnerName() string {\n\treturn l.owner.Username\n}\n\n// Path implements transfer.Lock.\nfunc (l *LFSLock) Path() string {\n\treturn l.lock.Path\n}\n\n// Unlock implements transfer.Lock.\nfunc (l *LFSLock) Unlock() error {\n\treturn l.backend.Unlock(l)\n}\n"
  },
  {
    "path": "pkg/git/lfs_auth.go",
    "content": "package git\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"charm.land/log/v2\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/jwk\"\n\t\"github.com/charmbracelet/soft-serve/pkg/lfs\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/golang-jwt/jwt/v5\"\n)\n\n// LFSAuthenticate implements the Git LFS SSH authentication command.\n// Context must have *config.Config, *log.Logger, proto.User.\n// cmd.Args should have the repo path and operation as arguments.\nfunc LFSAuthenticate(ctx context.Context, cmd ServiceCommand) error {\n\tif len(cmd.Args) < 2 {\n\t\treturn errors.New(\"missing args\")\n\t}\n\n\tlogger := log.FromContext(ctx).WithPrefix(\"ssh.lfs-authenticate\")\n\toperation := cmd.Args[1]\n\tif operation != lfs.OperationDownload && operation != lfs.OperationUpload {\n\t\tlogger.Errorf(\"invalid operation: %s\", operation)\n\t\treturn errors.New(\"invalid operation\")\n\t}\n\n\tuser := proto.UserFromContext(ctx)\n\tif user == nil {\n\t\tlogger.Errorf(\"missing user\")\n\t\treturn proto.ErrUserNotFound\n\t}\n\n\trepo := proto.RepositoryFromContext(ctx)\n\tif repo == nil {\n\t\tlogger.Errorf(\"missing repository\")\n\t\treturn proto.ErrRepoNotFound\n\t}\n\n\tcfg := config.FromContext(ctx)\n\tkp, err := jwk.NewPair(cfg)\n\tif err != nil {\n\t\tlogger.Error(\"failed to get JWK pair\", \"err\", err)\n\t\treturn err\n\t}\n\n\tnow := time.Now()\n\texpiresIn := time.Minute * 5\n\texpiresAt := now.Add(expiresIn)\n\tclaims := jwt.RegisteredClaims{\n\t\tSubject:   fmt.Sprintf(\"%s#%d\", user.Username(), user.ID()),\n\t\tExpiresAt: jwt.NewNumericDate(expiresAt), // expire in an hour\n\t\tNotBefore: jwt.NewNumericDate(now),\n\t\tIssuedAt:  jwt.NewNumericDate(now),\n\t\tIssuer:    cfg.HTTP.PublicURL,\n\t\tAudience: []string{\n\t\t\trepo.Name(),\n\t\t},\n\t}\n\n\ttoken := jwt.NewWithClaims(jwk.SigningMethod, claims)\n\ttoken.Header[\"kid\"] = kp.JWK().KeyID\n\tj, err := token.SignedString(kp.PrivateKey())\n\tif err != nil {\n\t\tlogger.Error(\"failed to sign token\", \"err\", err)\n\t\treturn err\n\t}\n\n\thref := fmt.Sprintf(\"%s/%s.git/info/lfs\", cfg.HTTP.PublicURL, repo.Name())\n\tlogger.Debug(\"generated token\", \"token\", j, \"href\", href, \"expires_at\", expiresAt)\n\n\treturn json.NewEncoder(cmd.Stdout).Encode(lfs.AuthenticateResponse{\n\t\tHeader: map[string]string{\n\t\t\t\"Authorization\": fmt.Sprintf(\"Bearer %s\", j),\n\t\t},\n\t\tHref:      href,\n\t\tExpiresAt: expiresAt,\n\t\tExpiresIn: expiresIn,\n\t})\n}\n"
  },
  {
    "path": "pkg/git/lfs_log.go",
    "content": "package git\n\nimport (\n\t\"charm.land/log/v2\"\n\t\"github.com/charmbracelet/git-lfs-transfer/transfer\"\n)\n\ntype lfsLogger struct {\n\tl *log.Logger\n}\n\nvar _ transfer.Logger = &lfsLogger{}\n\n// Log implements transfer.Logger.\nfunc (l *lfsLogger) Log(msg string, kv ...interface{}) {\n\tl.l.Debug(msg, kv...)\n}\n"
  },
  {
    "path": "pkg/git/service.go",
    "content": "package git\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"charm.land/log/v2\"\n)\n\n// Service is a Git daemon service.\ntype Service string\n\nconst (\n\t// UploadPackService is the upload-pack service.\n\tUploadPackService Service = \"git-upload-pack\"\n\t// UploadArchiveService is the upload-archive service.\n\tUploadArchiveService Service = \"git-upload-archive\"\n\t// ReceivePackService is the receive-pack service.\n\tReceivePackService Service = \"git-receive-pack\"\n\t// LFSTransferService is the LFS transfer service.\n\tLFSTransferService Service = \"git-lfs-transfer\"\n\t// LFSAuthenticateService is the LFS authenticate service.\n\tLFSAuthenticateService = \"git-lfs-authenticate\"\n)\n\n// String returns the string representation of the service.\nfunc (s Service) String() string {\n\treturn string(s)\n}\n\n// Name returns the name of the service.\nfunc (s Service) Name() string {\n\treturn strings.TrimPrefix(s.String(), \"git-\")\n}\n\n// Handler is the service handler.\nfunc (s Service) Handler(ctx context.Context, cmd ServiceCommand) error {\n\tswitch s {\n\tcase UploadPackService, UploadArchiveService, ReceivePackService:\n\t\treturn gitServiceHandler(ctx, s, cmd)\n\tcase LFSTransferService:\n\t\treturn LFSTransfer(ctx, cmd)\n\tcase LFSAuthenticateService:\n\t\treturn LFSAuthenticate(ctx, cmd)\n\tdefault:\n\t\treturn fmt.Errorf(\"unsupported service: %s\", s)\n\t}\n}\n\n// ServiceHandler is a git service command handler.\ntype ServiceHandler func(ctx context.Context, cmd ServiceCommand) error\n\n// gitServiceHandler is the default service handler using the git binary.\nfunc gitServiceHandler(ctx context.Context, svc Service, scmd ServiceCommand) error {\n\tcmd := exec.CommandContext(ctx, \"git\")\n\tcmd.Dir = scmd.Dir\n\tcmd.Args = append(cmd.Args, []string{\n\t\t// Enable partial clones\n\t\t\"-c\", \"uploadpack.allowFilter=true\",\n\t\t// Enable push options\n\t\t\"-c\", \"receive.advertisePushOptions=true\",\n\t\t// Disable LFS filters\n\t\t\"-c\", \"filter.lfs.required=\", \"-c\", \"filter.lfs.smudge=\", \"-c\", \"filter.lfs.clean=\",\n\t\tsvc.Name(),\n\t}...)\n\tif len(scmd.Args) > 0 {\n\t\tcmd.Args = append(cmd.Args, scmd.Args...)\n\t}\n\n\tcmd.Args = append(cmd.Args, \".\")\n\n\tcmd.Env = os.Environ()\n\tif len(scmd.Env) > 0 {\n\t\tcmd.Env = append(cmd.Env, scmd.Env...)\n\t}\n\n\tif scmd.CmdFunc != nil {\n\t\tscmd.CmdFunc(cmd)\n\t}\n\n\tvar (\n\t\terr    error\n\t\tstdin  io.WriteCloser\n\t\tstdout io.ReadCloser\n\t\tstderr io.ReadCloser\n\t)\n\n\tif scmd.Stdin != nil {\n\t\tstdin, err = cmd.StdinPipe()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif scmd.Stdout != nil {\n\t\tstdout, err = cmd.StdoutPipe()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif scmd.Stderr != nil {\n\t\tstderr, err = cmd.StderrPipe()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := cmd.Start(); err != nil {\n\t\tif errors.Is(err, os.ErrNotExist) {\n\t\t\treturn ErrInvalidRepo\n\t\t}\n\t\treturn err\n\t}\n\n\twg := &sync.WaitGroup{}\n\n\t// stdin\n\tif scmd.Stdin != nil {\n\t\tgo func() {\n\t\t\tdefer stdin.Close() //nolint: errcheck\n\t\t\tif _, err := io.Copy(stdin, scmd.Stdin); err != nil {\n\t\t\t\tlog.Errorf(\"gitServiceHandler: failed to copy stdin: %v\", err)\n\t\t\t}\n\t\t}()\n\t}\n\n\t// stdout\n\tif scmd.Stdout != nil {\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tif _, err := io.Copy(scmd.Stdout, stdout); err != nil {\n\t\t\t\tlog.Errorf(\"gitServiceHandler: failed to copy stdout: %v\", err)\n\t\t\t}\n\t\t}()\n\t}\n\n\t// stderr\n\tif scmd.Stderr != nil {\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tif _, erro := io.Copy(scmd.Stderr, stderr); err != nil {\n\t\t\t\tlog.Errorf(\"gitServiceHandler: failed to copy stderr: %v\", erro)\n\t\t\t}\n\t\t}()\n\t}\n\n\t// Ensure all the output is written before waiting for the command to\n\t// finish.\n\t// Stdin is handled by the client side.\n\twg.Wait()\n\n\terr = cmd.Wait()\n\tif err != nil && errors.Is(err, os.ErrNotExist) {\n\t\treturn ErrInvalidRepo\n\t} else if err != nil {\n\t\tvar exitErr *exec.ExitError\n\t\tif errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 {\n\t\t\treturn fmt.Errorf(\"%s: %s\", exitErr, exitErr.Stderr)\n\t\t}\n\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// ServiceCommand is used to run a git service command.\ntype ServiceCommand struct {\n\tStdin  io.Reader\n\tStdout io.Writer\n\tStderr io.Writer\n\tDir    string\n\tEnv    []string\n\tArgs   []string\n\n\t// Modifier functions\n\tCmdFunc func(*exec.Cmd)\n}\n\n// UploadPack runs the git upload-pack protocol against the provided repo.\nfunc UploadPack(ctx context.Context, cmd ServiceCommand) error {\n\treturn gitServiceHandler(ctx, UploadPackService, cmd)\n}\n\n// UploadArchive runs the git upload-archive protocol against the provided repo.\nfunc UploadArchive(ctx context.Context, cmd ServiceCommand) error {\n\treturn gitServiceHandler(ctx, UploadArchiveService, cmd)\n}\n\n// ReceivePack runs the git receive-pack protocol against the provided repo.\nfunc ReceivePack(ctx context.Context, cmd ServiceCommand) error {\n\treturn gitServiceHandler(ctx, ReceivePackService, cmd)\n}\n"
  },
  {
    "path": "pkg/hooks/gen.go",
    "content": "package hooks\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"text/template\"\n\n\t\"charm.land/log/v2\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/utils\"\n)\n\n// The names of git server-side hooks.\nconst (\n\tPreReceiveHook  = \"pre-receive\"\n\tUpdateHook      = \"update\"\n\tPostReceiveHook = \"post-receive\"\n\tPostUpdateHook  = \"post-update\"\n)\n\n// GenerateHooks generates git server-side hooks for a repository. Currently, it supports the following hooks:\n// - pre-receive\n// - update\n// - post-receive\n// - post-update\n//\n// This function should be called by the backend when a repository is created.\n// TODO: support context.\nfunc GenerateHooks(_ context.Context, cfg *config.Config, repo string) error {\n\trepo = utils.SanitizeRepo(repo) + \".git\"\n\thooksPath := filepath.Join(cfg.DataPath, \"repos\", repo, \"hooks\")\n\tif err := os.MkdirAll(hooksPath, os.ModePerm); err != nil {\n\t\treturn err\n\t}\n\n\tfor _, hook := range []string{\n\t\tPreReceiveHook,\n\t\tUpdateHook,\n\t\tPostReceiveHook,\n\t\tPostUpdateHook,\n\t} {\n\t\tvar data bytes.Buffer\n\t\tvar args string\n\n\t\t// Hooks script/directory path\n\t\thp := filepath.Join(hooksPath, hook)\n\n\t\t// Write the hooks primary script\n\t\tif err := os.WriteFile(hp, []byte(hookTemplate), os.ModePerm); err != nil { //nolint:gosec\n\t\t\treturn err\n\t\t}\n\n\t\t// Create ${hook}.d directory.\n\t\thp += \".d\"\n\t\tif err := os.MkdirAll(hp, os.ModePerm); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tswitch hook {\n\t\tcase UpdateHook:\n\t\t\targs = \"$1 $2 $3\"\n\t\tcase PostUpdateHook:\n\t\t\targs = \"$@\"\n\t\t}\n\n\t\tif err := hooksTmpl.Execute(&data, struct {\n\t\t\tExecutable string\n\t\t\tHook       string\n\t\t\tArgs       string\n\t\t}{\n\t\t\tExecutable: \"\\\"${SOFT_SERVE_BIN_PATH}\\\"\",\n\t\t\tHook:       hook,\n\t\t\tArgs:       args,\n\t\t}); err != nil {\n\t\t\tlog.WithPrefix(\"hooks\").Error(\"failed to execute hook template\", \"err\", err)\n\t\t\tcontinue\n\t\t}\n\n\t\t// Write the soft-serve hook inside ${hook}.d directory.\n\t\thp = filepath.Join(hp, \"soft-serve\")\n\t\terr := os.WriteFile(hp, data.Bytes(), os.ModePerm) //nolint:gosec\n\t\tif err != nil {\n\t\t\tlog.WithPrefix(\"hooks\").Error(\"failed to write hook\", \"err\", err)\n\t\t\tcontinue\n\t\t}\n\t}\n\n\treturn nil\n}\n\nconst (\n\t// hookTemplate allows us to run multiple hooks from a directory. It should\n\t// support every type of git hook, as it proxies both stdin and arguments.\n\thookTemplate = `#!/usr/bin/env bash\n# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY\ndata=$(cat)\nexitcodes=\"\"\nhookname=$(basename $0)\nGIT_DIR=${GIT_DIR:-$(dirname $0)/..}\nfor hook in ${GIT_DIR}/hooks/${hookname}.d/*; do\n  # Avoid running non-executable hooks\n  test -x \"${hook}\" && test -f \"${hook}\" || continue\n\n  # Run the actual hook\n  echo \"${data}\" | \"${hook}\" \"$@\"\n\n  # Store the exit code for later use\n  exitcodes=\"${exitcodes} $?\"\ndone\n\n# Exit on the first non-zero exit code.\nfor i in ${exitcodes}; do\n  [ ${i} -eq 0 ] || exit ${i}\ndone\n`\n)\n\n// hooksTmpl is the soft-serve hook that will be run by the git hooks\n// inside the hooks directory.\nvar hooksTmpl = template.Must(template.New(\"hooks\").Parse(`#!/usr/bin/env bash\n# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY\nif [ -z \"$SOFT_SERVE_REPO_NAME\" ]; then\n\techo \"Warning: SOFT_SERVE_REPO_NAME not defined. Skipping hooks.\"\n\texit 0\nfi\n{{ .Executable }} hook {{ .Hook }} {{ .Args }}\n`))\n"
  },
  {
    "path": "pkg/hooks/gen_test.go",
    "content": "package hooks\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/charmbracelet/soft-serve/git\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n)\n\nfunc TestGenerateHooks(t *testing.T) {\n\ttmp := t.TempDir()\n\tcfg := config.DefaultConfig()\n\tcfg.DataPath = tmp\n\trepoPath := filepath.Join(tmp, \"repos\", \"test.git\")\n\t_, err := git.Init(repoPath, true)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif err := GenerateHooks(context.TODO(), cfg, \"test.git\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfor _, hn := range []string{\n\t\tPreReceiveHook,\n\t\tUpdateHook,\n\t\tPostReceiveHook,\n\t\tPostUpdateHook,\n\t} {\n\t\tif _, err := os.Stat(filepath.Join(repoPath, \"hooks\", hn)); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif _, err := os.Stat(filepath.Join(repoPath, \"hooks\", hn+\".d\", \"soft-serve\")); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/hooks/hooks.go",
    "content": "package hooks\n\nimport (\n\t\"context\"\n\t\"io\"\n)\n\n// HookArg is an argument to a git hook.\ntype HookArg struct {\n\tOldSha  string\n\tNewSha  string\n\tRefName string\n}\n\n// Hooks provides an interface for git server-side hooks.\ntype Hooks interface {\n\tPreReceive(ctx context.Context, stdout io.Writer, stderr io.Writer, repo string, args []HookArg)\n\tUpdate(ctx context.Context, stdout io.Writer, stderr io.Writer, repo string, arg HookArg)\n\tPostReceive(ctx context.Context, stdout io.Writer, stderr io.Writer, repo string, args []HookArg)\n\tPostUpdate(ctx context.Context, stdout io.Writer, stderr io.Writer, repo string, args ...string)\n}\n"
  },
  {
    "path": "pkg/jobs/jobs.go",
    "content": "package jobs\n\nimport (\n\t\"context\"\n\t\"sync\"\n)\n\n// Job is a job that can be registered with the scheduler.\ntype Job struct {\n\tID     int\n\tRunner Runner\n}\n\n// Runner is a job runner.\ntype Runner interface {\n\tSpec(context.Context) string\n\tFunc(context.Context) func()\n}\n\nvar (\n\tmtx  sync.Mutex\n\tjobs = make(map[string]*Job, 0)\n)\n\n// Register registers a job.\nfunc Register(name string, runner Runner) {\n\tmtx.Lock()\n\tdefer mtx.Unlock()\n\tjobs[name] = &Job{Runner: runner}\n}\n\n// List returns a map of registered jobs.\nfunc List() map[string]*Job {\n\tmtx.Lock()\n\tdefer mtx.Unlock()\n\treturn jobs\n}\n"
  },
  {
    "path": "pkg/jobs/mirror.go",
    "content": "package jobs\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\n\t\"charm.land/log/v2\"\n\t\"github.com/charmbracelet/soft-serve/git\"\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/lfs\"\n\t\"github.com/charmbracelet/soft-serve/pkg/store\"\n\t\"github.com/charmbracelet/soft-serve/pkg/sync\"\n)\n\nfunc init() {\n\tRegister(\"mirror-pull\", mirrorPull{})\n}\n\ntype mirrorPull struct{}\n\n// Spec derives the spec used for pull mirrors and implements Runner.\nfunc (m mirrorPull) Spec(ctx context.Context) string {\n\tcfg := config.FromContext(ctx)\n\tif cfg.Jobs.MirrorPull != \"\" {\n\t\treturn cfg.Jobs.MirrorPull\n\t}\n\treturn \"@every 10m\"\n}\n\n// Func runs the (pull) mirror job task and implements Runner.\nfunc (m mirrorPull) Func(ctx context.Context) func() {\n\tcfg := config.FromContext(ctx)\n\tlogger := log.FromContext(ctx).WithPrefix(\"jobs.mirror\")\n\tb := backend.FromContext(ctx)\n\tdbx := db.FromContext(ctx)\n\tdatastore := store.FromContext(ctx)\n\treturn func() {\n\t\trepos, err := b.Repositories(ctx)\n\t\tif err != nil {\n\t\t\tlogger.Error(\"error getting repositories\", \"err\", err)\n\t\t\treturn\n\t\t}\n\n\t\t// Divide the work up among the number of CPUs.\n\t\twq := sync.NewWorkPool(ctx, runtime.GOMAXPROCS(0),\n\t\t\tsync.WithWorkPoolLogger(logger.Errorf),\n\t\t)\n\n\t\tlogger.Debug(\"updating mirror repos\")\n\t\tfor _, repo := range repos {\n\t\t\tif repo.IsMirror() {\n\t\t\t\tr, err := repo.Open()\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.Error(\"error opening repository\", \"repo\", repo.Name(), \"err\", err)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tname := repo.Name()\n\t\t\t\twq.Add(name, func() {\n\t\t\t\t\trepo := repo\n\n\t\t\t\t\tcmds := []string{\n\t\t\t\t\t\t\"fetch --prune\",         // fetch prune before updating remote\n\t\t\t\t\t\t\"remote update --prune\", // update remote and prune remote refs\n\t\t\t\t\t}\n\n\t\t\t\t\tfor _, c := range cmds {\n\t\t\t\t\t\targs := strings.Split(c, \" \")\n\t\t\t\t\t\tcmd := git.NewCommand(args...).WithContext(ctx)\n\t\t\t\t\t\tcmd.AddEnvs(\n\t\t\t\t\t\t\tfmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile=\"%s\" -o StrictHostKeyChecking=no -i \"%s\"`,\n\t\t\t\t\t\t\t\tfilepath.Join(cfg.DataPath, \"ssh\", \"known_hosts\"),\n\t\t\t\t\t\t\t\tcfg.SSH.ClientKeyPath,\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t)\n\n\t\t\t\t\t\tif _, err := cmd.RunInDir(r.Path); err != nil {\n\t\t\t\t\t\t\tlogger.Error(\"error running git remote update\", \"repo\", name, \"err\", err)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tif cfg.LFS.Enabled {\n\t\t\t\t\t\trcfg, err := r.Config()\n\t\t\t\t\t\tif err != nil {\n\t\t\t\t\t\t\tlogger.Error(\"error getting git config\", \"repo\", name, \"err\", err)\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tlfsEndpoint := rcfg.Section(\"lfs\").Option(\"url\")\n\t\t\t\t\t\tif lfsEndpoint == \"\" {\n\t\t\t\t\t\t\t// If there is no LFS url defined, means the repo\n\t\t\t\t\t\t\t// doesn't use LFS and we can skip it.\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tep, err := lfs.NewEndpoint(lfsEndpoint)\n\t\t\t\t\t\tif err != nil {\n\t\t\t\t\t\t\tlogger.Error(\"error creating LFS endpoint\", \"repo\", name, \"err\", err)\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tclient := lfs.NewClient(ep)\n\t\t\t\t\t\tif client == nil {\n\t\t\t\t\t\t\tlogger.Errorf(\"failed to create lfs client: unsupported endpoint %s\", lfsEndpoint)\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tif err := backend.StoreRepoMissingLFSObjects(ctx, repo, dbx, datastore, client); err != nil {\n\t\t\t\t\t\t\tlogger.Error(\"failed to store missing lfs objects\", \"err\", err, \"path\", r.Path)\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\n\t\twq.Run()\n\t}\n}\n"
  },
  {
    "path": "pkg/jwk/jwk.go",
    "content": "package jwk\n\nimport (\n\t\"crypto\"\n\t\"crypto/sha256\"\n\t\"fmt\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/go-jose/go-jose/v3\"\n\t\"github.com/golang-jwt/jwt/v5\"\n)\n\n// SigningMethod is a JSON Web Token signing method. It uses Ed25519 keys to\n// sign and verify tokens.\nvar SigningMethod = &jwt.SigningMethodEd25519{}\n\n// Pair is a JSON Web Key pair.\ntype Pair struct {\n\tprivateKey crypto.PrivateKey\n\tjwk        jose.JSONWebKey\n}\n\n// PrivateKey returns the private key.\nfunc (p Pair) PrivateKey() crypto.PrivateKey {\n\treturn p.privateKey\n}\n\n// JWK returns the JSON Web Key.\nfunc (p Pair) JWK() jose.JSONWebKey {\n\treturn p.jwk\n}\n\n// NewPair creates a new JSON Web Key pair.\nfunc NewPair(cfg *config.Config) (Pair, error) {\n\tkp, err := config.KeyPair(cfg)\n\tif err != nil {\n\t\treturn Pair{}, err\n\t}\n\n\tsum := sha256.Sum256(kp.RawPrivateKey())\n\tkid := fmt.Sprintf(\"%x\", sum)\n\tjwk := jose.JSONWebKey{\n\t\tKey:       kp.CryptoPublicKey(),\n\t\tKeyID:     kid,\n\t\tAlgorithm: SigningMethod.Alg(),\n\t}\n\n\treturn Pair{privateKey: kp.PrivateKey(), jwk: jwk}, nil\n}\n"
  },
  {
    "path": "pkg/jwk/jwk_test.go",
    "content": "package jwk\n\nimport (\n\t\"errors\"\n\t\"testing\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n)\n\nfunc TestBadNewPair(t *testing.T) {\n\t_, err := NewPair(nil)\n\tif !errors.Is(err, config.ErrNilConfig) {\n\t\tt.Errorf(\"NewPair(nil) => %v, want %v\", err, config.ErrNilConfig)\n\t}\n}\n\nfunc TestGoodNewPair(t *testing.T) {\n\tcfg := config.DefaultConfig()\n\tif _, err := NewPair(cfg); err != nil {\n\t\tt.Errorf(\"NewPair(cfg) => _, %v, want nil error\", err)\n\t}\n}\n"
  },
  {
    "path": "pkg/lfs/basic_transfer.go",
    "content": "package lfs\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"charm.land/log/v2\"\n)\n\n// BasicTransferAdapter implements the \"basic\" adapter\ntype BasicTransferAdapter struct {\n\tclient *http.Client\n}\n\n// Name returns the name of the adapter\nfunc (a *BasicTransferAdapter) Name() string {\n\treturn \"basic\"\n}\n\n// Download reads the download location and downloads the data\nfunc (a *BasicTransferAdapter) Download(ctx context.Context, _ Pointer, l *Link) (io.ReadCloser, error) {\n\tresp, err := a.performRequest(ctx, \"GET\", l, nil, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn resp.Body, nil\n}\n\n// Upload sends the content to the LFS server\nfunc (a *BasicTransferAdapter) Upload(ctx context.Context, p Pointer, r io.Reader, l *Link) error {\n\tres, err := a.performRequest(ctx, \"PUT\", l, r, func(req *http.Request) {\n\t\tif len(req.Header.Get(\"Content-Type\")) == 0 {\n\t\t\treq.Header.Set(\"Content-Type\", \"application/octet-stream\")\n\t\t}\n\n\t\tif req.Header.Get(\"Transfer-Encoding\") == \"chunked\" {\n\t\t\treq.TransferEncoding = []string{\"chunked\"}\n\t\t}\n\n\t\treq.ContentLength = p.Size\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn res.Body.Close()\n}\n\n// Verify calls the verify handler on the LFS server\nfunc (a *BasicTransferAdapter) Verify(ctx context.Context, p Pointer, l *Link) error {\n\tlogger := log.FromContext(ctx).WithPrefix(\"lfs\")\n\tb, err := json.Marshal(p)\n\tif err != nil {\n\t\tlogger.Errorf(\"Error encoding json: %v\", err)\n\t\treturn err\n\t}\n\n\tres, err := a.performRequest(ctx, \"POST\", l, bytes.NewReader(b), func(req *http.Request) {\n\t\treq.Header.Set(\"Content-Type\", MediaType)\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn res.Body.Close()\n}\n\nfunc (a *BasicTransferAdapter) performRequest(ctx context.Context, method string, l *Link, body io.Reader, callback func(*http.Request)) (*http.Response, error) {\n\tlogger := log.FromContext(ctx).WithPrefix(\"lfs\")\n\tlogger.Debugf(\"Calling: %s %s\", method, l.Href)\n\n\treq, err := http.NewRequestWithContext(ctx, method, l.Href, body)\n\tif err != nil {\n\t\tlogger.Errorf(\"Error creating request: %v\", err)\n\t\treturn nil, err\n\t}\n\tfor key, value := range l.Header {\n\t\treq.Header.Set(key, value)\n\t}\n\treq.Header.Set(\"Accept\", MediaType)\n\n\tif callback != nil {\n\t\tcallback(req)\n\t}\n\n\tres, err := a.client.Do(req)\n\tif err != nil {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn res, ctx.Err()\n\t\tdefault:\n\t\t}\n\t\tlogger.Errorf(\"Error while processing request: %v\", err)\n\t\treturn res, err\n\t}\n\n\tif res.StatusCode != http.StatusOK {\n\t\treturn res, handleErrorResponse(res)\n\t}\n\n\treturn res, nil\n}\n\nfunc handleErrorResponse(resp *http.Response) error {\n\tdefer resp.Body.Close() //nolint: errcheck\n\n\ter, err := decodeResponseError(resp.Body)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"request failed with status %s\", resp.Status)\n\t}\n\treturn errors.New(er.Message)\n}\n\nfunc decodeResponseError(r io.Reader) (ErrorResponse, error) {\n\tvar er ErrorResponse\n\terr := json.NewDecoder(r).Decode(&er)\n\tif err != nil {\n\t\tlog.Error(\"Error decoding json: %v\", err)\n\t}\n\treturn er, err\n}\n"
  },
  {
    "path": "pkg/lfs/client.go",
    "content": "package lfs\n\nimport (\n\t\"context\"\n\t\"io\"\n)\n\n// DownloadCallback gets called for every requested LFS object to process its content\ntype DownloadCallback func(p Pointer, content io.ReadCloser, objectError error) error\n\n// UploadCallback gets called for every requested LFS object to provide its content\ntype UploadCallback func(p Pointer, objectError error) (io.ReadCloser, error)\n\n// Client is a Git LFS client to communicate with a LFS source API.\ntype Client interface {\n\tDownload(ctx context.Context, objects []Pointer, callback DownloadCallback) error\n\tUpload(ctx context.Context, objects []Pointer, callback UploadCallback) error\n}\n\n// NewClient returns a new Git LFS client.\nfunc NewClient(e Endpoint) Client {\n\tif e.Scheme == \"http\" || e.Scheme == \"https\" {\n\t\treturn newHTTPClient(e)\n\t}\n\t// TODO: support ssh client\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/lfs/common.go",
    "content": "package lfs\n\nimport (\n\t\"time\"\n)\n\nconst (\n\t// MediaType contains the media type for LFS server requests.\n\tMediaType = \"application/vnd.git-lfs+json\"\n\n\t// OperationDownload is the operation name for a download request.\n\tOperationDownload = \"download\"\n\n\t// OperationUpload is the operation name for an upload request.\n\tOperationUpload = \"upload\"\n\n\t// ActionDownload is the action name for a download request.\n\tActionDownload = OperationDownload\n\n\t// ActionUpload is the action name for an upload request.\n\tActionUpload = OperationUpload\n\n\t// ActionVerify is the action name for a verify request.\n\tActionVerify = \"verify\"\n\n\t// DefaultLocksLimit is the default number of locks to return in a single\n\t// request.\n\tDefaultLocksLimit = 20\n)\n\n// Pointer contains LFS pointer data\ntype Pointer struct {\n\tOid  string `json:\"oid\"`\n\tSize int64  `json:\"size\"`\n}\n\n// PointerBlob associates a Git blob with a Pointer.\ntype PointerBlob struct {\n\tHash string\n\tPointer\n}\n\n// ErrorResponse describes the error to the client.\ntype ErrorResponse struct {\n\tMessage          string `json:\"message,omitempty\"`\n\tDocumentationURL string `json:\"documentation_url,omitempty\"`\n\tRequestID        string `json:\"request_id,omitempty\"`\n}\n\n// BatchResponse contains multiple object metadata Representation structures\n// for use with the batch API.\n// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md#successful-responses\ntype BatchResponse struct {\n\tTransfer string            `json:\"transfer,omitempty\"`\n\tObjects  []*ObjectResponse `json:\"objects\"`\n\tHashAlgo string            `json:\"hash_algo,omitempty\"`\n}\n\n// ObjectResponse is object metadata as seen by clients of the LFS server.\ntype ObjectResponse struct {\n\tPointer\n\tActions map[string]*Link `json:\"actions,omitempty\"`\n\tError   *ObjectError     `json:\"error,omitempty\"`\n}\n\n// Link provides a structure with information about how to access a object.\ntype Link struct {\n\tHref      string            `json:\"href\"`\n\tHeader    map[string]string `json:\"header,omitempty\"`\n\tExpiresAt *time.Time        `json:\"expires_at,omitempty\"`\n\tExpiresIn *time.Duration    `json:\"expires_in,omitempty\"`\n}\n\n// ObjectError defines the JSON structure returned to the client in case of an error.\ntype ObjectError struct {\n\tCode    int    `json:\"code\"`\n\tMessage string `json:\"message\"`\n}\n\n// BatchRequest contains multiple requests processed in one batch operation.\n// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md#requests\ntype BatchRequest struct {\n\tOperation string     `json:\"operation\"`\n\tTransfers []string   `json:\"transfers,omitempty\"`\n\tRef       *Reference `json:\"ref,omitempty\"`\n\tObjects   []Pointer  `json:\"objects\"`\n\tHashAlgo  string     `json:\"hash_algo,omitempty\"`\n}\n\n// Reference contains a git reference.\n// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md#ref-property\ntype Reference struct {\n\tName string `json:\"name\"`\n}\n\n// AuthenticateResponse is the git-lfs-authenticate JSON response object.\ntype AuthenticateResponse struct {\n\tHeader    map[string]string `json:\"header\"`\n\tHref      string            `json:\"href\"`\n\tExpiresIn time.Duration     `json:\"expires_in\"`\n\tExpiresAt time.Time         `json:\"expires_at\"`\n}\n\n// LockCreateRequest contains the request data for creating a lock.\n// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md\n// https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-create-request-schema.json\ntype LockCreateRequest struct {\n\tPath string    `json:\"path\"`\n\tRef  Reference `json:\"ref,omitempty\"`\n}\n\n// Owner contains the owner data for a lock.\ntype Owner struct {\n\tName string `json:\"name\"`\n}\n\n// Lock contains the response data for creating a lock.\n// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md\n// https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-create-response-schema.json\ntype Lock struct {\n\tID       string    `json:\"id\"`\n\tPath     string    `json:\"path\"`\n\tLockedAt time.Time `json:\"locked_at\"`\n\tOwner    Owner     `json:\"owner,omitempty\"`\n}\n\n// LockDeleteRequest contains the request data for deleting a lock.\n// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md\n// https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-delete-request-schema.json\ntype LockDeleteRequest struct {\n\tForce bool      `json:\"force,omitempty\"`\n\tRef   Reference `json:\"ref,omitempty\"`\n}\n\n// LockListResponse contains the response data for listing locks.\n// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md\n// https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-list-response-schema.json\ntype LockListResponse struct {\n\tLocks      []Lock `json:\"locks\"`\n\tNextCursor string `json:\"next_cursor,omitempty\"`\n}\n\n// LockVerifyRequest contains the request data for verifying a lock.\ntype LockVerifyRequest struct {\n\tRef    Reference `json:\"ref,omitempty\"`\n\tCursor string    `json:\"cursor,omitempty\"`\n\tLimit  int       `json:\"limit,omitempty\"`\n}\n\n// LockVerifyResponse contains the response data for verifying a lock.\n// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md\n// https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-verify-response-schema.json\ntype LockVerifyResponse struct {\n\tOurs       []Lock `json:\"ours\"`\n\tTheirs     []Lock `json:\"theirs\"`\n\tNextCursor string `json:\"next_cursor,omitempty\"`\n}\n\n// LockResponse contains the response data for a lock.\ntype LockResponse struct {\n\tLock Lock `json:\"lock\"`\n\tErrorResponse\n}\n"
  },
  {
    "path": "pkg/lfs/endpoint.go",
    "content": "package lfs\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n\t\"strings\"\n)\n\n// Endpoint is a Git LFS endpoint.\ntype Endpoint = *url.URL\n\n// NewEndpoint returns a new Git LFS endpoint.\nfunc NewEndpoint(rawurl string) (Endpoint, error) {\n\tu, err := url.Parse(rawurl)\n\tif err != nil {\n\t\te, err := endpointFromBareSSH(rawurl)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tu = e\n\t}\n\n\tu.Path = strings.TrimSuffix(u.Path, \"/\")\n\n\tswitch u.Scheme {\n\tcase \"git\":\n\t\t// Use https for git:// URLs and strip the port if it exists.\n\t\tu.Scheme = \"https\"\n\t\tif u.Port() != \"\" {\n\t\t\tu.Host = u.Hostname()\n\t\t}\n\t\tfallthrough\n\tcase \"http\", \"https\":\n\t\tif strings.HasSuffix(u.Path, \".git\") {\n\t\t\tu.Path += \"/info/lfs\"\n\t\t} else {\n\t\t\tu.Path += \".git/info/lfs\"\n\t\t}\n\tcase \"ssh\", \"git+ssh\", \"ssh+git\":\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unknown url: %s\", rawurl)\n\t}\n\n\treturn u, nil\n}\n\n// endpointFromBareSSH creates a new endpoint from a bare ssh repo.\n//\n//\tuser@host.com:path/to/repo.git or\n//\t[user@host.com:port]:path/to/repo.git\nfunc endpointFromBareSSH(rawurl string) (*url.URL, error) {\n\tparts := strings.Split(rawurl, \":\")\n\tpartsLen := len(parts)\n\tif partsLen < 2 {\n\t\treturn url.Parse(rawurl)\n\t}\n\n\t// Treat presence of ':' as a bare URL\n\tvar newPath string\n\tif len(parts) > 2 { // port included; really should only ever be 3 parts\n\t\t// Correctly handle [host:port]:path URLs\n\t\tparts[0] = strings.TrimPrefix(parts[0], \"[\")\n\t\tparts[1] = strings.TrimSuffix(parts[1], \"]\")\n\t\tnewPath = fmt.Sprintf(\"%v:%v\", parts[0], strings.Join(parts[1:], \"/\"))\n\t} else {\n\t\tnewPath = strings.Join(parts, \"/\")\n\t}\n\tnewrawurl := fmt.Sprintf(\"ssh://%v\", newPath)\n\treturn url.Parse(newrawurl)\n}\n"
  },
  {
    "path": "pkg/lfs/http_client.go",
    "content": "package lfs\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"charm.land/log/v2\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ssrf\"\n)\n\n// httpClient is a Git LFS client to communicate with a LFS source API.\ntype httpClient struct {\n\tclient    *http.Client\n\tendpoint  Endpoint\n\ttransfers map[string]TransferAdapter\n}\n\nvar _ Client = (*httpClient)(nil)\n\n// newHTTPClient returns a new Git LFS client.\nfunc newHTTPClient(endpoint Endpoint) *httpClient {\n\tclient := ssrf.NewSecureClient()\n\treturn &httpClient{\n\t\tclient:   client,\n\t\tendpoint: endpoint,\n\t\ttransfers: map[string]TransferAdapter{\n\t\t\tTransferBasic: &BasicTransferAdapter{client},\n\t\t},\n\t}\n}\n\n// Download implements Client.\nfunc (c *httpClient) Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error {\n\treturn c.performOperation(ctx, objects, callback, nil)\n}\n\n// Upload implements Client.\nfunc (c *httpClient) Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error {\n\treturn c.performOperation(ctx, objects, nil, callback)\n}\n\nfunc (c *httpClient) transferNames() []string {\n\tnames := make([]string, len(c.transfers))\n\ti := 0\n\tfor name := range c.transfers {\n\t\tnames[i] = name\n\t\ti++\n\t}\n\treturn names\n}\n\n// batch performs a batch request to the LFS server.\nfunc (c *httpClient) batch(ctx context.Context, operation string, objects []Pointer) (*BatchResponse, error) {\n\tlogger := log.FromContext(ctx).WithPrefix(\"lfs\")\n\turl := fmt.Sprintf(\"%s/objects/batch\", c.endpoint.String())\n\n\t// TODO: support ref\n\trequest := &BatchRequest{operation, c.transferNames(), nil, objects, HashAlgorithmSHA256}\n\n\tpayload := new(bytes.Buffer)\n\terr := json.NewEncoder(payload).Encode(request)\n\tif err != nil {\n\t\tlogger.Errorf(\"Error encoding json: %v\", err)\n\t\treturn nil, err\n\t}\n\n\tlogger.Debugf(\"Calling: %s\", url)\n\n\treq, err := http.NewRequestWithContext(ctx, \"POST\", url, payload)\n\tif err != nil {\n\t\tlogger.Errorf(\"Error creating request: %v\", err)\n\t\treturn nil, err\n\t}\n\treq.Header.Set(\"Content-type\", MediaType)\n\treq.Header.Set(\"Accept\", MediaType)\n\n\tres, err := c.client.Do(req)\n\tif err != nil {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn nil, ctx.Err()\n\t\tdefault:\n\t\t}\n\t\tlogger.Errorf(\"Error while processing request: %v\", err)\n\t\treturn nil, err\n\t}\n\tdefer res.Body.Close() //nolint: errcheck\n\n\tif res.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"unexpected server response: %s\", res.Status)\n\t}\n\n\tvar response BatchResponse\n\terr = json.NewDecoder(res.Body).Decode(&response)\n\tif err != nil {\n\t\tlogger.Errorf(\"Error decoding json: %v\", err)\n\t\treturn nil, err\n\t}\n\n\tif len(response.Transfer) == 0 {\n\t\tresponse.Transfer = TransferBasic\n\t}\n\n\treturn &response, nil\n}\n\nfunc (c *httpClient) performOperation(ctx context.Context, objects []Pointer, dc DownloadCallback, uc UploadCallback) error {\n\tlogger := log.FromContext(ctx).WithPrefix(\"lfs\")\n\tif len(objects) == 0 {\n\t\treturn nil\n\t}\n\n\toperation := OperationDownload\n\tif uc != nil {\n\t\toperation = OperationUpload\n\t}\n\n\tresult, err := c.batch(ctx, operation, objects)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttransferAdapter, ok := c.transfers[result.Transfer]\n\tif !ok {\n\t\treturn fmt.Errorf(\"TransferAdapter not found: %s\", result.Transfer)\n\t}\n\n\tfor _, object := range result.Objects {\n\t\tif object.Error != nil {\n\t\t\tobjectError := errors.New(object.Error.Message)\n\t\t\tlogger.Debugf(\"Error on object %v: %v\", object.Pointer, objectError)\n\t\t\tif uc != nil {\n\t\t\t\tif _, err := uc(object.Pointer, objectError); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err := dc(object.Pointer, nil, objectError); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\tif uc != nil {\n\t\t\tif len(object.Actions) == 0 {\n\t\t\t\tlogger.Debugf(\"%v already present on server\", object.Pointer)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tlink, ok := object.Actions[ActionUpload]\n\t\t\tif !ok {\n\t\t\t\tlogger.Debugf(\"%+v\", object)\n\t\t\t\treturn errors.New(\"missing action 'upload'\")\n\t\t\t}\n\n\t\t\tcontent, err := uc(object.Pointer, nil)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\terr = transferAdapter.Upload(ctx, object.Pointer, content, link)\n\n\t\t\tcontent.Close() //nolint: errcheck\n\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tlink, ok = object.Actions[ActionVerify]\n\t\t\tif ok {\n\t\t\t\tif err := transferAdapter.Verify(ctx, object.Pointer, link); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tlink, ok := object.Actions[ActionDownload]\n\t\t\tif !ok {\n\t\t\t\tlogger.Debugf(\"%+v\", object)\n\t\t\t\treturn errors.New(\"missing action 'download'\")\n\t\t\t}\n\n\t\t\tcontent, err := transferAdapter.Download(ctx, object.Pointer, link)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif err := dc(object.Pointer, content, nil); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/lfs/pointer.go",
    "content": "package lfs\n\nimport (\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"path\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n)\n\nconst (\n\tblobSizeCutoff = 1024\n\n\t// HashAlgorithmSHA256 is the hash algorithm used for Git LFS.\n\tHashAlgorithmSHA256 = \"sha256\"\n\n\t// MetaFileIdentifier is the string appearing at the first line of LFS pointer files.\n\t// https://github.com/git-lfs/git-lfs/blob/master/docs/spec.md\n\tMetaFileIdentifier = \"version https://git-lfs.github.com/spec/v1\"\n\n\t// MetaFileOidPrefix appears in LFS pointer files on a line before the sha256 hash.\n\tMetaFileOidPrefix = \"oid \" + HashAlgorithmSHA256 + \":\"\n)\n\nvar (\n\t// ErrMissingPrefix occurs if the content lacks the LFS prefix\n\tErrMissingPrefix = errors.New(\"content lacks the LFS prefix\")\n\n\t// ErrInvalidStructure occurs if the content has an invalid structure\n\tErrInvalidStructure = errors.New(\"content has an invalid structure\")\n\n\t// ErrInvalidOIDFormat occurs if the oid has an invalid format\n\tErrInvalidOIDFormat = errors.New(\"OID has an invalid format\")\n)\n\n// ReadPointer tries to read LFS pointer data from the reader\nfunc ReadPointer(reader io.Reader) (Pointer, error) {\n\tbuf := make([]byte, blobSizeCutoff)\n\tn, err := io.ReadFull(reader, buf)\n\tif err != nil && err != io.ErrUnexpectedEOF {\n\t\treturn Pointer{}, err\n\t}\n\tbuf = buf[:n]\n\n\treturn ReadPointerFromBuffer(buf)\n}\n\nvar oidPattern = regexp.MustCompile(`^[a-f\\d]{64}$`)\n\n// ReadPointerFromBuffer will return a pointer if the provided byte slice is a pointer file or an error otherwise.\nfunc ReadPointerFromBuffer(buf []byte) (Pointer, error) {\n\tvar p Pointer\n\n\theadString := string(buf)\n\tif !strings.HasPrefix(headString, MetaFileIdentifier) {\n\t\treturn p, ErrMissingPrefix\n\t}\n\n\tsplitLines := strings.Split(headString, \"\\n\")\n\tif len(splitLines) < 3 {\n\t\treturn p, ErrInvalidStructure\n\t}\n\n\toid := strings.TrimPrefix(splitLines[1], MetaFileOidPrefix)\n\tif len(oid) != 64 || !oidPattern.MatchString(oid) {\n\t\treturn p, ErrInvalidOIDFormat\n\t}\n\tsize, err := strconv.ParseInt(strings.TrimPrefix(splitLines[2], \"size \"), 10, 64)\n\tif err != nil {\n\t\treturn p, err\n\t}\n\n\tp.Oid = oid\n\tp.Size = size\n\n\treturn p, nil\n}\n\n// IsValid checks if the pointer has a valid structure.\n// It doesn't check if the pointed-to-content exists.\nfunc (p Pointer) IsValid() bool {\n\tif len(p.Oid) != 64 {\n\t\treturn false\n\t}\n\tif !oidPattern.MatchString(p.Oid) {\n\t\treturn false\n\t}\n\tif p.Size < 0 {\n\t\treturn false\n\t}\n\treturn true\n}\n\n// String returns the string representation of the pointer\n// https://github.com/git-lfs/git-lfs/blob/main/docs/spec.md#the-pointer\nfunc (p Pointer) String() string {\n\treturn fmt.Sprintf(\"%s\\n%s%s\\nsize %d\\n\", MetaFileIdentifier, MetaFileOidPrefix, p.Oid, p.Size)\n}\n\n// RelativePath returns the relative storage path of the pointer\n// https://github.com/git-lfs/git-lfs/blob/main/docs/spec.md#intercepting-git\nfunc (p Pointer) RelativePath() string {\n\tif len(p.Oid) < 5 {\n\t\treturn p.Oid\n\t}\n\n\treturn path.Join(p.Oid[0:2], p.Oid[2:4], p.Oid)\n}\n\n// GeneratePointer generates a pointer for arbitrary content\nfunc GeneratePointer(content io.Reader) (Pointer, error) {\n\th := sha256.New()\n\tc, err := io.Copy(h, content)\n\tif err != nil {\n\t\treturn Pointer{}, err\n\t}\n\tsum := h.Sum(nil)\n\treturn Pointer{Oid: hex.EncodeToString(sum), Size: c}, nil\n}\n"
  },
  {
    "path": "pkg/lfs/pointer_test.go",
    "content": "package lfs\n\nimport (\n\t\"errors\"\n\t\"path\"\n\t\"strconv\"\n\t\"testing\"\n)\n\nfunc TestReadPointer(t *testing.T) {\n\tcases := []struct {\n\t\tname     string\n\t\tcontent  string\n\t\twant     Pointer\n\t\twantErr  error\n\t\twantErrp interface{}\n\t}{\n\t\t{\n\t\t\tname: \"valid pointer\",\n\t\t\tcontent: `version https://git-lfs.github.com/spec/v1\noid sha256:1234567890123456789012345678901234567890123456789012345678901234\nsize 1234\n`,\n\t\t\twant: Pointer{\n\t\t\t\tOid:  \"1234567890123456789012345678901234567890123456789012345678901234\",\n\t\t\t\tSize: 1234,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"invalid prefix\",\n\t\t\tcontent: `version https://foobar/spec/v2\noid sha256:1234567890123456789012345678901234567890123456789012345678901234\nsize 1234\n`,\n\t\t\twantErr: ErrMissingPrefix,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid oid\",\n\t\t\tcontent: `version https://git-lfs.github.com/spec/v1\noid sha256:&2345a78$012345678901234567890123456789012345678901234567890123\nsize 1234\n`,\n\t\t\twantErr: ErrInvalidOIDFormat,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid size\",\n\t\t\tcontent: `version https://git-lfs.github.com/spec/v1\noid sha256:1234567890123456789012345678901234567890123456789012345678901234\nsize abc\n`,\n\t\t\twantErrp: &strconv.NumError{},\n\t\t},\n\t\t{\n\t\t\tname: \"invalid structure\",\n\t\t\tcontent: `version https://git-lfs.github.com/spec/v1\n`,\n\t\t\twantErr: ErrInvalidStructure,\n\t\t},\n\t\t{\n\t\t\tname:    \"empty pointer\",\n\t\t\twantErr: ErrMissingPrefix,\n\t\t},\n\t}\n\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tp, err := ReadPointerFromBuffer([]byte(tc.content))\n\t\t\tif err != tc.wantErr && !errors.As(err, &tc.wantErrp) {\n\t\t\t\tt.Errorf(\"ReadPointerFromBuffer() error = %v(%T), wantErr %v(%T)\", err, err, tc.wantErr, tc.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err == nil {\n\t\t\t\tif !p.IsValid() {\n\t\t\t\t\tt.Errorf(\"Expected a valid pointer\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif path.Join(p.Oid[:2], p.Oid[2:4], p.Oid) != p.RelativePath() {\n\t\t\t\t\tt.Errorf(\"Expected a valid relative path\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif p.Oid != tc.want.Oid {\n\t\t\t\tt.Errorf(\"ReadPointerFromBuffer() oid = %v, want %v\", p.Oid, tc.want.Oid)\n\t\t\t}\n\t\t\tif p.Size != tc.want.Size {\n\t\t\t\tt.Errorf(\"ReadPointerFromBuffer() size = %v, want %v\", p.Size, tc.want.Size)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/lfs/scanner.go",
    "content": "package lfs\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\n\tgitm \"github.com/aymanbagabas/git-module\"\n\t\"github.com/charmbracelet/soft-serve/git\"\n)\n\n// SearchPointerBlobs scans the whole repository for LFS pointer files\nfunc SearchPointerBlobs(ctx context.Context, repo *git.Repository, pointerChan chan<- PointerBlob, errChan chan<- error) {\n\tbasePath := repo.Path\n\n\tcatFileCheckReader, catFileCheckWriter := io.Pipe()\n\tshasToBatchReader, shasToBatchWriter := io.Pipe()\n\tcatFileBatchReader, catFileBatchWriter := io.Pipe()\n\n\twg := sync.WaitGroup{}\n\twg.Add(6)\n\n\t// Create the go-routines in reverse order.\n\n\t// 4. Take the output of cat-file --batch and check if each file in turn\n\t// to see if they're pointers to files in the LFS store\n\tgo createPointerResultsFromCatFileBatch(ctx, catFileBatchReader, &wg, pointerChan)\n\n\t// 3. Take the shas of the blobs and batch read them\n\tgo catFileBatch(ctx, shasToBatchReader, catFileBatchWriter, &wg, basePath)\n\n\t// 2. From the provided objects restrict to blobs <=1k\n\tgo blobsLessThan1024FromCatFileBatchCheck(catFileCheckReader, shasToBatchWriter, &wg)\n\n\t// 1. Run batch-check on all objects in the repository\n\trevListReader, revListWriter := io.Pipe()\n\tshasToCheckReader, shasToCheckWriter := io.Pipe()\n\tgo catFileBatchCheck(ctx, shasToCheckReader, catFileCheckWriter, &wg, basePath)\n\tgo blobsFromRevListObjects(revListReader, shasToCheckWriter, &wg)\n\tgo revListAllObjects(ctx, revListWriter, &wg, basePath, errChan)\n\twg.Wait()\n\n\tclose(pointerChan)\n\tclose(errChan)\n}\n\nfunc createPointerResultsFromCatFileBatch(ctx context.Context, catFileBatchReader *io.PipeReader, wg *sync.WaitGroup, pointerChan chan<- PointerBlob) {\n\tdefer wg.Done()\n\tdefer catFileBatchReader.Close() //nolint: errcheck\n\n\tbufferedReader := bufio.NewReader(catFileBatchReader)\n\tbuf := make([]byte, 1025)\n\nloop:\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tbreak loop\n\t\tdefault:\n\t\t}\n\n\t\t// File descriptor line: sha\n\t\tsha, err := bufferedReader.ReadString(' ')\n\t\tif err != nil {\n\t\t\t_ = catFileBatchReader.CloseWithError(err)\n\t\t\tbreak\n\t\t}\n\t\tsha = strings.TrimSpace(sha)\n\t\t// Throw away the blob\n\t\tif _, err := bufferedReader.ReadString(' '); err != nil {\n\t\t\t_ = catFileBatchReader.CloseWithError(err)\n\t\t\tbreak\n\t\t}\n\t\tsizeStr, err := bufferedReader.ReadString('\\n')\n\t\tif err != nil {\n\t\t\t_ = catFileBatchReader.CloseWithError(err)\n\t\t\tbreak\n\t\t}\n\t\tsize, err := strconv.Atoi(sizeStr[:len(sizeStr)-1])\n\t\tif err != nil {\n\t\t\t_ = catFileBatchReader.CloseWithError(err)\n\t\t\tbreak\n\t\t}\n\t\tpointerBuf := buf[:size+1]\n\t\tif _, err := io.ReadFull(bufferedReader, pointerBuf); err != nil {\n\t\t\t_ = catFileBatchReader.CloseWithError(err)\n\t\t\tbreak\n\t\t}\n\t\tpointerBuf = pointerBuf[:size]\n\t\t// Now we need to check if the pointerBuf is an LFS pointer\n\t\tpointer, _ := ReadPointerFromBuffer(pointerBuf)\n\t\tif !pointer.IsValid() {\n\t\t\tcontinue\n\t\t}\n\n\t\tpointerChan <- PointerBlob{Hash: sha, Pointer: pointer}\n\t}\n}\n\nfunc catFileBatch(ctx context.Context, shasToBatchReader *io.PipeReader, catFileBatchWriter *io.PipeWriter, wg *sync.WaitGroup, basePath string) {\n\tdefer wg.Done()\n\tdefer shasToBatchReader.Close()  //nolint: errcheck\n\tdefer catFileBatchWriter.Close() //nolint: errcheck\n\n\tstderr := new(bytes.Buffer)\n\tvar errbuf strings.Builder\n\tif err := gitm.NewCommandWithContext(ctx, \"cat-file\", \"--batch\").\n\t\tWithTimeout(-1).\n\t\tRunInDirWithOptions(basePath, gitm.RunInDirOptions{\n\t\t\tStdout: catFileBatchWriter,\n\t\t\tStdin:  shasToBatchReader,\n\t\t\tStderr: stderr,\n\t\t}); err != nil {\n\t\t_ = shasToBatchReader.CloseWithError(fmt.Errorf(\"git rev-list [%s]: %w - %s\", basePath, err, errbuf.String()))\n\t}\n}\n\nfunc blobsLessThan1024FromCatFileBatchCheck(catFileCheckReader *io.PipeReader, shasToBatchWriter *io.PipeWriter, wg *sync.WaitGroup) {\n\tdefer wg.Done()\n\tdefer catFileCheckReader.Close() //nolint: errcheck\n\tscanner := bufio.NewScanner(catFileCheckReader)\n\tdefer func() {\n\t\t_ = shasToBatchWriter.CloseWithError(scanner.Err())\n\t}()\n\tfor scanner.Scan() {\n\t\tline := scanner.Text()\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tfields := strings.Split(line, \" \")\n\t\tif len(fields) < 3 || fields[1] != \"blob\" {\n\t\t\tcontinue\n\t\t}\n\t\tsize, _ := strconv.Atoi(fields[2])\n\t\tif size > 1024 {\n\t\t\tcontinue\n\t\t}\n\t\ttoWrite := []byte(fields[0] + \"\\n\")\n\t\tfor len(toWrite) > 0 {\n\t\t\tn, err := shasToBatchWriter.Write(toWrite)\n\t\t\tif err != nil {\n\t\t\t\t_ = catFileCheckReader.CloseWithError(err)\n\t\t\t\tbreak\n\t\t\t}\n\t\t\ttoWrite = toWrite[n:]\n\t\t}\n\t}\n}\n\nfunc catFileBatchCheck(ctx context.Context, shasToCheckReader *io.PipeReader, catFileCheckWriter *io.PipeWriter, wg *sync.WaitGroup, basePath string) {\n\tdefer wg.Done()\n\tdefer shasToCheckReader.Close()  //nolint: errcheck\n\tdefer catFileCheckWriter.Close() //nolint: errcheck\n\n\tstderr := new(bytes.Buffer)\n\tvar errbuf strings.Builder\n\tif err := gitm.NewCommandWithContext(ctx, \"cat-file\", \"--batch-check\").\n\t\tWithTimeout(-1).\n\t\tRunInDirWithOptions(basePath, gitm.RunInDirOptions{\n\t\t\tStdout: catFileCheckWriter,\n\t\t\tStdin:  shasToCheckReader,\n\t\t\tStderr: stderr,\n\t\t}); err != nil {\n\t\t_ = shasToCheckReader.CloseWithError(fmt.Errorf(\"git rev-list [%s]: %w - %s\", basePath, err, errbuf.String()))\n\t}\n}\n\nfunc blobsFromRevListObjects(revListReader *io.PipeReader, shasToCheckWriter *io.PipeWriter, wg *sync.WaitGroup) {\n\tdefer wg.Done()\n\tdefer revListReader.Close() //nolint: errcheck\n\tscanner := bufio.NewScanner(revListReader)\n\tdefer func() {\n\t\t_ = shasToCheckWriter.CloseWithError(scanner.Err())\n\t}()\n\n\tfor scanner.Scan() {\n\t\tline := scanner.Text()\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tfields := strings.Split(line, \" \")\n\t\tif len(fields) < 2 || len(fields[1]) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\ttoWrite := []byte(fields[0] + \"\\n\")\n\t\tfor len(toWrite) > 0 {\n\t\t\tn, err := shasToCheckWriter.Write(toWrite)\n\t\t\tif err != nil {\n\t\t\t\t_ = revListReader.CloseWithError(err)\n\t\t\t\tbreak\n\t\t\t}\n\t\t\ttoWrite = toWrite[n:]\n\t\t}\n\t}\n}\n\nfunc revListAllObjects(ctx context.Context, revListWriter *io.PipeWriter, wg *sync.WaitGroup, basePath string, errChan chan<- error) {\n\tdefer wg.Done()\n\tdefer revListWriter.Close() //nolint: errcheck\n\n\tstderr := new(bytes.Buffer)\n\tvar errbuf strings.Builder\n\tif err := gitm.NewCommandWithContext(ctx, \"rev-list\", \"--objects\", \"--all\").\n\t\tWithTimeout(-1).\n\t\tRunInDirWithOptions(basePath, gitm.RunInDirOptions{\n\t\t\tStdout: revListWriter,\n\t\t\tStderr: stderr,\n\t\t}); err != nil {\n\t\terrChan <- fmt.Errorf(\"git rev-list [%s]: %w - %s\", basePath, err, errbuf.String())\n\t}\n}\n"
  },
  {
    "path": "pkg/lfs/ssh_client.go",
    "content": "package lfs\n\n// TODO: implement Git LFS SSH client.\n"
  },
  {
    "path": "pkg/lfs/transfer.go",
    "content": "package lfs\n\nimport (\n\t\"context\"\n\t\"io\"\n)\n\n// TransferBasic is the name of the Git LFS basic transfer protocol.\nconst TransferBasic = \"basic\"\n\n// TransferAdapter represents an adapter for downloading/uploading LFS objects\ntype TransferAdapter interface {\n\tName() string\n\tDownload(ctx context.Context, p Pointer, l *Link) (io.ReadCloser, error)\n\tUpload(ctx context.Context, p Pointer, r io.Reader, l *Link) error\n\tVerify(ctx context.Context, p Pointer, l *Link) error\n}\n"
  },
  {
    "path": "pkg/log/log.go",
    "content": "package log\n\nimport (\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"charm.land/log/v2\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n)\n\n// NewLogger returns a new logger with default settings.\nfunc NewLogger(cfg *config.Config) (*log.Logger, *os.File, error) {\n\tif cfg == nil {\n\t\treturn nil, nil, config.ErrNilConfig\n\t}\n\tlogger := log.NewWithOptions(os.Stderr, log.Options{\n\t\tReportTimestamp: true,\n\t\tTimeFormat:      time.DateOnly,\n\t})\n\n\tswitch {\n\tcase config.IsVerbose():\n\t\tlogger.SetReportCaller(true)\n\t\tfallthrough\n\tcase config.IsDebug():\n\t\tlogger.SetLevel(log.DebugLevel)\n\t}\n\n\tlogger.SetTimeFormat(cfg.Log.TimeFormat)\n\n\tswitch strings.ToLower(cfg.Log.Format) {\n\tcase \"json\":\n\t\tlogger.SetFormatter(log.JSONFormatter)\n\tcase \"logfmt\":\n\t\tlogger.SetFormatter(log.LogfmtFormatter)\n\tcase \"text\":\n\t\tlogger.SetFormatter(log.TextFormatter)\n\t}\n\n\tvar f *os.File\n\tif cfg.Log.Path != \"\" {\n\t\tvar err error\n\t\tf, err = os.OpenFile(cfg.Log.Path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)\n\t\tif err != nil {\n\t\t\treturn nil, nil, err\n\t\t}\n\t\tlogger.SetOutput(f)\n\t}\n\n\treturn logger, f, nil\n}\n"
  },
  {
    "path": "pkg/log/log_test.go",
    "content": "package log\n\nimport (\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n)\n\nfunc TestGoodNewLogger(t *testing.T) {\n\tfor _, c := range []*config.Config{\n\t\tconfig.DefaultConfig(),\n\t\t{},\n\t\t{Log: config.LogConfig{Path: filepath.Join(t.TempDir(), \"logfile.txt\")}},\n\t} {\n\t\t_, f, err := NewLogger(c)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"expected nil got %v\", err)\n\t\t}\n\t\tif f != nil {\n\t\t\tif err := f.Close(); err != nil {\n\t\t\t\tt.Errorf(\"failed to close logger: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestBadNewLogger(t *testing.T) {\n\tfor _, c := range []*config.Config{\n\t\tnil,\n\t\t{Log: config.LogConfig{Path: \"\\x00\"}},\n\t} {\n\t\t_, f, err := NewLogger(c)\n\t\tif err == nil {\n\t\t\tt.Errorf(\"expected error got nil\")\n\t\t}\n\t\tif f != nil {\n\t\t\tif err := f.Close(); err != nil {\n\t\t\t\tt.Errorf(\"failed to close logger: %v\", err)\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/proto/access_token.go",
    "content": "package proto\n\nimport \"time\"\n\n// AccessToken represents an access token.\ntype AccessToken struct {\n\tID        int64\n\tName      string\n\tUserID    int64\n\tTokenHash string\n\tExpiresAt time.Time\n\tCreatedAt time.Time\n}\n"
  },
  {
    "path": "pkg/proto/context.go",
    "content": "package proto\n\nimport \"context\"\n\n// ContextKeyRepository is the context key for the repository.\nvar ContextKeyRepository = &struct{ string }{\"repository\"}\n\n// ContextKeyUser is the context key for the user.\nvar ContextKeyUser = &struct{ string }{\"user\"}\n\n// RepositoryFromContext returns the repository from the context.\nfunc RepositoryFromContext(ctx context.Context) Repository {\n\tif r, ok := ctx.Value(ContextKeyRepository).(Repository); ok {\n\t\treturn r\n\t}\n\treturn nil\n}\n\n// UserFromContext returns the user from the context.\nfunc UserFromContext(ctx context.Context) User {\n\tif u, ok := ctx.Value(ContextKeyUser).(User); ok {\n\t\treturn u\n\t}\n\treturn nil\n}\n\n// WithRepositoryContext returns a new context with the repository.\nfunc WithRepositoryContext(ctx context.Context, r Repository) context.Context {\n\treturn context.WithValue(ctx, ContextKeyRepository, r)\n}\n\n// WithUserContext returns a new context with the user.\nfunc WithUserContext(ctx context.Context, u User) context.Context {\n\treturn context.WithValue(ctx, ContextKeyUser, u)\n}\n"
  },
  {
    "path": "pkg/proto/errors.go",
    "content": "package proto\n\nimport (\n\t\"errors\"\n)\n\nvar (\n\t// ErrUnauthorized is returned when the user is not authorized to perform action.\n\tErrUnauthorized = errors.New(\"unauthorized\")\n\t// ErrInvalidRemote is returned when a repository import remote is invalid.\n\tErrInvalidRemote = errors.New(\"remote must be a network URL\")\n\t// ErrFileNotFound is returned when the file is not found.\n\tErrFileNotFound = errors.New(\"file not found\")\n\t// ErrRepoNotFound is returned when a repository is not found.\n\tErrRepoNotFound = errors.New(\"repository not found\")\n\t// ErrRepoExist is returned when a repository already exists.\n\tErrRepoExist = errors.New(\"repository already exists\")\n\t// ErrUserNotFound is returned when a user is not found.\n\tErrUserNotFound = errors.New(\"user not found\")\n\t// ErrTokenNotFound is returned when a token is not found.\n\tErrTokenNotFound = errors.New(\"token not found\")\n\t// ErrTokenExpired is returned when a token is expired.\n\tErrTokenExpired = errors.New(\"token expired\")\n\t// ErrCollaboratorNotFound is returned when a collaborator is not found.\n\tErrCollaboratorNotFound = errors.New(\"collaborator not found\")\n\t// ErrCollaboratorExist is returned when a collaborator already exists.\n\tErrCollaboratorExist = errors.New(\"collaborator already exists\")\n)\n"
  },
  {
    "path": "pkg/proto/repo.go",
    "content": "package proto\n\nimport (\n\t\"time\"\n\n\t\"github.com/charmbracelet/soft-serve/git\"\n)\n\n// Repository is a Git repository interface.\ntype Repository interface {\n\t// ID returns the repository's ID.\n\tID() int64\n\t// Name returns the repository's name.\n\tName() string\n\t// ProjectName returns the repository's project name.\n\tProjectName() string\n\t// Description returns the repository's description.\n\tDescription() string\n\t// IsPrivate returns whether the repository is private.\n\tIsPrivate() bool\n\t// IsMirror returns whether the repository is a mirror.\n\tIsMirror() bool\n\t// IsHidden returns whether the repository is hidden.\n\tIsHidden() bool\n\t// UserID returns the ID of the user who owns the repository.\n\t// It returns 0 if the repository is not owned by a user.\n\tUserID() int64\n\t// CreatedAt returns the time the repository was created.\n\tCreatedAt() time.Time\n\t// UpdatedAt returns the time the repository was last updated.\n\t// If the repository has never been updated, it returns the time it was created.\n\tUpdatedAt() time.Time\n\t// Open returns the underlying git.Repository.\n\tOpen() (*git.Repository, error)\n}\n\n// RepositoryOptions are options for creating a new repository.\ntype RepositoryOptions struct {\n\tPrivate     bool\n\tDescription string\n\tProjectName string\n\tMirror      bool\n\tHidden      bool\n\tLFS         bool\n\tLFSEndpoint string\n}\n\n// RepositoryDefaultBranch returns the default branch of a repository.\nfunc RepositoryDefaultBranch(repo Repository) (string, error) {\n\tr, err := repo.Open()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tref, err := r.HEAD()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn ref.Name().Short(), nil\n}\n"
  },
  {
    "path": "pkg/proto/user.go",
    "content": "package proto\n\nimport \"golang.org/x/crypto/ssh\"\n\n// User is an interface representing a user.\ntype User interface {\n\t// ID returns the user's ID.\n\tID() int64\n\t// Username returns the user's username.\n\tUsername() string\n\t// IsAdmin returns whether the user is an admin.\n\tIsAdmin() bool\n\t// PublicKeys returns the user's public keys.\n\tPublicKeys() []ssh.PublicKey\n\t// Password returns the user's password hash.\n\tPassword() string\n}\n\n// UserOptions are options for creating a user.\ntype UserOptions struct {\n\t// Admin is whether the user is an admin.\n\tAdmin bool\n\t// PublicKeys are the user's public keys.\n\tPublicKeys []ssh.PublicKey\n}\n"
  },
  {
    "path": "pkg/ssh/cmd/blob.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/charmbracelet/soft-serve/git\"\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/common\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/styles\"\n\t\"github.com/spf13/cobra\"\n)\n\n// blobCommand returns a command that prints the contents of a file.\nfunc blobCommand() *cobra.Command {\n\tvar linenumber bool\n\tvar color bool\n\tvar raw bool\n\tvar noColor bool\n\tif testrun, ok := os.LookupEnv(\"SOFT_SERVE_NO_COLOR\"); ok && testrun == \"1\" {\n\t\tnoColor = true\n\t}\n\n\tstyles := styles.DefaultStyles()\n\tcmd := &cobra.Command{\n\t\tUse:               \"blob REPOSITORY [REFERENCE] [PATH]\",\n\t\tAliases:           []string{\"cat\", \"show\"},\n\t\tShort:             \"Print out the contents of file at path\",\n\t\tArgs:              cobra.RangeArgs(1, 3),\n\t\tPersistentPreRunE: checkIfReadable,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\trn := args[0]\n\t\t\tref := \"\"\n\t\t\tfp := \"\"\n\t\t\tswitch len(args) {\n\t\t\tcase 2:\n\t\t\t\tfp = args[1]\n\t\t\tcase 3:\n\t\t\t\tref = args[1]\n\t\t\t\tfp = args[2]\n\t\t\t}\n\n\t\t\trepo, err := be.Repository(ctx, rn)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tr, err := repo.Open()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif ref == \"\" {\n\t\t\t\thead, err := r.HEAD()\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tref = head.ID\n\t\t\t}\n\n\t\t\ttree, err := r.LsTree(ref)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tte, err := tree.TreeEntry(fp)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif te.Type() != \"blob\" {\n\t\t\t\treturn git.ErrFileNotFound\n\t\t\t}\n\n\t\t\tbts, err := te.Contents()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tc := string(bts)\n\t\t\tisBin, _ := te.File().IsBinary()\n\t\t\tif isBin {\n\t\t\t\tif raw {\n\t\t\t\t\tcmd.Println(c)\n\t\t\t\t} else {\n\t\t\t\t\treturn fmt.Errorf(\"binary file: use --raw to print\")\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif color && !noColor {\n\t\t\t\t\tc, err = common.FormatHighlight(fp, c)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif linenumber {\n\t\t\t\t\tc, _ = common.FormatLineNumber(styles, c, color && !noColor)\n\t\t\t\t}\n\n\t\t\t\tcmd.Println(c)\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tcmd.Flags().BoolVarP(&raw, \"raw\", \"r\", false, \"Print raw contents\")\n\tcmd.Flags().BoolVarP(&linenumber, \"linenumber\", \"l\", false, \"Print line numbers\")\n\tcmd.Flags().BoolVarP(&color, \"color\", \"c\", false, \"Colorize output\")\n\n\treturn cmd\n}\n"
  },
  {
    "path": "pkg/ssh/cmd/branch.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\tgitm \"github.com/aymanbagabas/git-module\"\n\t\"github.com/charmbracelet/soft-serve/git\"\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/webhook\"\n\t\"github.com/spf13/cobra\"\n)\n\nfunc branchCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"branch\",\n\t\tShort: \"Manage repository branches\",\n\t}\n\n\tcmd.AddCommand(\n\t\tbranchListCommand(),\n\t\tbranchDefaultCommand(),\n\t\tbranchDeleteCommand(),\n\t)\n\n\treturn cmd\n}\n\nfunc branchListCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:               \"list REPOSITORY\",\n\t\tShort:             \"List repository branches\",\n\t\tArgs:              cobra.ExactArgs(1),\n\t\tPersistentPreRunE: checkIfReadable,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\trn := strings.TrimSuffix(args[0], \".git\")\n\t\t\trr, err := be.Repository(ctx, rn)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tr, err := rr.Open()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tbranches, _ := r.Branches()\n\t\t\tfor _, b := range branches {\n\t\t\t\tcmd.Println(b)\n\t\t\t}\n\n\t\t\treturn nil\n\t\t},\n\t}\n\n\treturn cmd\n}\n\nfunc branchDefaultCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:               \"default REPOSITORY [BRANCH]\",\n\t\tShort:             \"Set or get the default branch\",\n\t\tArgs:              cobra.RangeArgs(1, 2),\n\t\tPersistentPreRunE: checkIfReadable,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\trn := strings.TrimSuffix(args[0], \".git\")\n\t\t\tswitch len(args) {\n\t\t\tcase 1:\n\t\t\t\trr, err := be.Repository(ctx, rn)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tr, err := rr.Open()\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\thead, err := r.HEAD()\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tcmd.Println(head.Name().Short())\n\t\t\tcase 2:\n\t\t\t\tif err := checkIfCollab(cmd, args); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\trr, err := be.Repository(ctx, rn)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tr, err := rr.Open()\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tbranch := args[1]\n\t\t\t\tbranches, _ := r.Branches()\n\t\t\t\tvar exists bool\n\t\t\t\tfor _, b := range branches {\n\t\t\t\t\tif branch == b {\n\t\t\t\t\t\texists = true\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif !exists {\n\t\t\t\t\treturn git.ErrReferenceNotExist\n\t\t\t\t}\n\n\t\t\t\tif _, err := r.SymbolicRef(git.HEAD, gitm.RefsHeads+branch, gitm.SymbolicRefOptions{\n\t\t\t\t\tCommandOptions: gitm.CommandOptions{\n\t\t\t\t\t\tContext: ctx,\n\t\t\t\t\t},\n\t\t\t\t}); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\t// TODO: move this to backend?\n\t\t\t\tuser := proto.UserFromContext(ctx)\n\t\t\t\twh, err := webhook.NewRepositoryEvent(ctx, user, rr, webhook.RepositoryEventActionDefaultBranchChange)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\treturn webhook.SendEvent(ctx, wh)\n\t\t\t}\n\n\t\t\treturn nil\n\t\t},\n\t}\n\n\treturn cmd\n}\n\nfunc branchDeleteCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:               \"delete REPOSITORY BRANCH\",\n\t\tAliases:           []string{\"remove\", \"rm\", \"del\"},\n\t\tShort:             \"Delete a branch\",\n\t\tArgs:              cobra.ExactArgs(2),\n\t\tPersistentPreRunE: checkIfReadableAndCollab,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\trn := strings.TrimSuffix(args[0], \".git\")\n\t\t\trr, err := be.Repository(ctx, rn)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tr, err := rr.Open()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tbranch := args[1]\n\t\t\tbranches, _ := r.Branches()\n\t\t\tvar exists bool\n\t\t\tfor _, b := range branches {\n\t\t\t\tif branch == b {\n\t\t\t\t\texists = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif !exists {\n\t\t\t\treturn git.ErrReferenceNotExist\n\t\t\t}\n\n\t\t\thead, err := r.HEAD()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif head.Name().Short() == branch {\n\t\t\t\treturn fmt.Errorf(\"cannot delete the default branch\")\n\t\t\t}\n\n\t\t\tbranchCommit, err := r.BranchCommit(branch)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif err := r.DeleteBranch(branch, gitm.DeleteBranchOptions{Force: true}); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\twh, err := webhook.NewBranchTagEvent(ctx, proto.UserFromContext(ctx), rr, git.RefsHeads+branch, branchCommit.ID.String(), git.ZeroID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\treturn webhook.SendEvent(ctx, wh)\n\t\t},\n\t}\n\n\treturn cmd\n}\n"
  },
  {
    "path": "pkg/ssh/cmd/cmd.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n\t\"strings\"\n\t\"text/template\"\n\t\"unicode\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/access\"\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/sshutils\"\n\t\"github.com/charmbracelet/soft-serve/pkg/utils\"\n\t\"github.com/charmbracelet/ssh\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar templateFuncs = template.FuncMap{\n\t\"trim\":                    strings.TrimSpace,\n\t\"trimRightSpace\":          trimRightSpace,\n\t\"trimTrailingWhitespaces\": trimRightSpace,\n\t\"rpad\":                    rpad,\n\t\"gt\":                      cobra.Gt,\n\t\"eq\":                      cobra.Eq,\n}\n\nconst (\n\t// UsageTemplate is the template used for the help output.\n\tUsageTemplate = `Usage:{{if .Runnable}}\n  {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}\n  {{.SSHCommand}}{{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}}\n\nAliases:\n  {{.NameAndAliases}}{{end}}{{if .HasExample}}\n\nExamples:\n{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}\n\nAvailable Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name \"help\"))}}\n  {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}\n\n{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name \"help\")))}}\n  {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}\n\nAdditional Commands:{{range $cmds}}{{if (and (eq .GroupID \"\") (or .IsAvailableCommand (eq .Name \"help\")))}}\n  {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}\n\nFlags:\n{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}\n\nGlobal Flags:\n{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}\n\nAdditional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}\n  {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}\n\nUse \"{{.SSHCommand}}{{.CommandPath}} [command] --help\" for more information about a command.{{end}}\n`\n)\n\n// UsageFunc is a function that can be used as a cobra.Command's\n// UsageFunc to render the help output.\nfunc UsageFunc(c *cobra.Command) error {\n\tctx := c.Context()\n\tcfg := config.FromContext(ctx)\n\thostname := \"localhost\"\n\tport := \"23231\"\n\turl, err := url.Parse(cfg.SSH.PublicURL)\n\tif err == nil {\n\t\thostname = url.Hostname()\n\t\tport = url.Port()\n\t}\n\n\tsshCmd := \"ssh\"\n\tif port != \"\" && port != \"22\" {\n\t\tsshCmd += \" -p \" + port\n\t}\n\n\tsshCmd += \" \" + hostname\n\tt := template.New(\"usage\")\n\tt.Funcs(templateFuncs)\n\ttemplate.Must(t.Parse(c.UsageTemplate()))\n\treturn t.Execute(c.OutOrStderr(), struct {\n\t\t*cobra.Command\n\t\tSSHCommand string\n\t}{\n\t\tCommand:    c,\n\t\tSSHCommand: sshCmd,\n\t})\n}\n\nfunc trimRightSpace(s string) string {\n\treturn strings.TrimRightFunc(s, unicode.IsSpace)\n}\n\n// rpad adds padding to the right of a string.\nfunc rpad(s string, padding int) string {\n\ttemplate := fmt.Sprintf(\"%%-%ds\", padding)\n\treturn fmt.Sprintf(template, s)\n}\n\n// CommandName returns the name of the command from the args.\nfunc CommandName(args []string) string {\n\tif len(args) == 0 {\n\t\treturn \"\"\n\t}\n\treturn args[0]\n}\n\nfunc checkIfReadable(cmd *cobra.Command, args []string) error {\n\tvar repo string\n\tif len(args) > 0 {\n\t\trepo = args[0]\n\t}\n\n\tctx := cmd.Context()\n\tbe := backend.FromContext(ctx)\n\trn := utils.SanitizeRepo(repo)\n\tuser := proto.UserFromContext(ctx)\n\tauth := be.AccessLevelForUser(cmd.Context(), rn, user)\n\tif auth < access.ReadOnlyAccess {\n\t\treturn proto.ErrRepoNotFound\n\t}\n\treturn nil\n}\n\n// IsPublicKeyAdmin returns true if the given public key is an admin key from\n// the initial_admin_keys config or environment field.\nfunc IsPublicKeyAdmin(cfg *config.Config, pk ssh.PublicKey) bool {\n\tfor _, k := range cfg.AdminKeys() {\n\t\tif sshutils.KeysEqual(pk, k) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc checkIfAdmin(cmd *cobra.Command, args []string) error {\n\tvar repo string\n\tif len(args) > 0 {\n\t\trepo = args[0]\n\t}\n\n\tctx := cmd.Context()\n\tcfg := config.FromContext(ctx)\n\tbe := backend.FromContext(ctx)\n\trn := utils.SanitizeRepo(repo)\n\tpk := sshutils.PublicKeyFromContext(ctx)\n\tif IsPublicKeyAdmin(cfg, pk) {\n\t\treturn nil\n\t}\n\n\tuser := proto.UserFromContext(ctx)\n\tif user == nil {\n\t\treturn proto.ErrUnauthorized\n\t}\n\n\tif user.IsAdmin() {\n\t\treturn nil\n\t}\n\n\tauth := be.AccessLevelForUser(cmd.Context(), rn, user)\n\tif auth >= access.AdminAccess {\n\t\treturn nil\n\t}\n\n\treturn proto.ErrUnauthorized\n}\n\nfunc checkIfCollab(cmd *cobra.Command, args []string) error {\n\tvar repo string\n\tif len(args) > 0 {\n\t\trepo = args[0]\n\t}\n\n\tctx := cmd.Context()\n\tbe := backend.FromContext(ctx)\n\trn := utils.SanitizeRepo(repo)\n\tuser := proto.UserFromContext(ctx)\n\tauth := be.AccessLevelForUser(cmd.Context(), rn, user)\n\tif auth < access.ReadWriteAccess {\n\t\treturn proto.ErrUnauthorized\n\t}\n\treturn nil\n}\n\nfunc checkIfReadableAndCollab(cmd *cobra.Command, args []string) error {\n\tif err := checkIfReadable(cmd, args); err != nil {\n\t\treturn err\n\t}\n\tif err := checkIfCollab(cmd, args); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/ssh/cmd/collab.go",
    "content": "package cmd\n\nimport (\n\t\"github.com/charmbracelet/soft-serve/pkg/access\"\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/spf13/cobra\"\n)\n\nfunc collabCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:     \"collab\",\n\t\tAliases: []string{\"collabs\", \"collaborator\", \"collaborators\"},\n\t\tShort:   \"Manage collaborators\",\n\t}\n\n\tcmd.AddCommand(\n\t\tcollabAddCommand(),\n\t\tcollabRemoveCommand(),\n\t\tcollabListCommand(),\n\t)\n\n\treturn cmd\n}\n\nfunc collabAddCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:               \"add REPOSITORY USERNAME [LEVEL]\",\n\t\tShort:             \"Add a collaborator to a repo\",\n\t\tLong:              \"Add a collaborator to a repo. LEVEL can be one of: no-access, read-only, read-write, or admin-access. Defaults to read-write.\",\n\t\tArgs:              cobra.RangeArgs(2, 3),\n\t\tPersistentPreRunE: checkIfReadableAndCollab,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\trepo := args[0]\n\t\t\tusername := args[1]\n\t\t\tlevel := access.ReadWriteAccess\n\t\t\tif len(args) > 2 {\n\t\t\t\tlevel = access.ParseAccessLevel(args[2])\n\t\t\t\tif level < 0 {\n\t\t\t\t\treturn access.ErrInvalidAccessLevel\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn be.AddCollaborator(ctx, repo, username, level)\n\t\t},\n\t}\n\n\treturn cmd\n}\n\nfunc collabRemoveCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:               \"remove REPOSITORY USERNAME\",\n\t\tArgs:              cobra.ExactArgs(2),\n\t\tShort:             \"Remove a collaborator from a repo\",\n\t\tPersistentPreRunE: checkIfReadableAndCollab,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\trepo := args[0]\n\t\t\tusername := args[1]\n\n\t\t\treturn be.RemoveCollaborator(ctx, repo, username)\n\t\t},\n\t}\n\n\treturn cmd\n}\n\nfunc collabListCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:               \"list REPOSITORY\",\n\t\tShort:             \"List collaborators for a repo\",\n\t\tArgs:              cobra.ExactArgs(1),\n\t\tPersistentPreRunE: checkIfReadableAndCollab,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\trepo := args[0]\n\t\t\tcollabs, err := be.Collaborators(ctx, repo)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tfor _, c := range collabs {\n\t\t\t\tcmd.Println(c)\n\t\t\t}\n\n\t\t\treturn nil\n\t\t},\n\t}\n\n\treturn cmd\n}\n"
  },
  {
    "path": "pkg/ssh/cmd/commit.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\tgansi \"charm.land/glamour/v2/ansi\"\n\t\"github.com/charmbracelet/soft-serve/git\"\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/common\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/styles\"\n\t\"github.com/charmbracelet/soft-serve/pkg/utils\"\n\t\"github.com/spf13/cobra\"\n)\n\n// commitCommand returns a command that prints the contents of a commit.\nfunc commitCommand() *cobra.Command {\n\tvar color bool\n\tvar patchOnly bool\n\n\tcmd := &cobra.Command{\n\t\tUse:               \"commit repo SHA\",\n\t\tShort:             \"Print out the contents of a diff\",\n\t\tArgs:              cobra.ExactArgs(2),\n\t\tPersistentPreRunE: checkIfReadable,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\trepoName := args[0]\n\t\t\tcommitSHA := args[1]\n\n\t\t\trr, err := be.Repository(ctx, repoName)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tr, err := rr.Open()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tcommit, err := r.CommitByRevision(commitSHA)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tpatch, err := r.Patch(commit)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tdiff, err := r.Diff(commit)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tcommonStyle := styles.DefaultStyles()\n\t\t\tstyle := commonStyle.Log\n\n\t\t\ts := strings.Builder{}\n\t\t\tcommitLine := \"commit \" + commitSHA\n\t\t\tauthorLine := \"Author: \" + utils.Sanitize(commit.Author.Name)\n\t\t\tdateLine := \"Date:   \" + commit.Committer.When.UTC().Format(time.UnixDate)\n\t\t\tmsgLine := strings.ReplaceAll(utils.Sanitize(commit.Message), \"\\r\\n\", \"\\n\")\n\t\t\tstatsLine := renderStats(diff, commonStyle, color)\n\t\t\tdiffLine := renderDiff(patch, color)\n\n\t\t\tif patchOnly {\n\t\t\t\tcmd.Println(\n\t\t\t\t\tdiffLine,\n\t\t\t\t)\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tif color {\n\t\t\t\ts.WriteString(fmt.Sprintf(\"%s\\n%s\\n%s\\n%s\\n\",\n\t\t\t\t\tstyle.CommitHash.Render(commitLine),\n\t\t\t\t\tstyle.CommitAuthor.Render(authorLine),\n\t\t\t\t\tstyle.CommitDate.Render(dateLine),\n\t\t\t\t\tstyle.CommitBody.Render(msgLine),\n\t\t\t\t))\n\t\t\t} else {\n\t\t\t\ts.WriteString(fmt.Sprintf(\"%s\\n%s\\n%s\\n%s\\n\",\n\t\t\t\t\tcommitLine,\n\t\t\t\t\tauthorLine,\n\t\t\t\t\tdateLine,\n\t\t\t\t\tmsgLine,\n\t\t\t\t))\n\t\t\t}\n\n\t\t\ts.WriteString(fmt.Sprintf(\"\\n%s\\n%s\",\n\t\t\t\tstatsLine,\n\t\t\t\tdiffLine,\n\t\t\t))\n\n\t\t\tcmd.Println(\n\t\t\t\ts.String(),\n\t\t\t)\n\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tcmd.Flags().BoolVarP(&color, \"color\", \"c\", false, \"Colorize output\")\n\tcmd.Flags().BoolVarP(&patchOnly, \"patch\", \"p\", false, \"Output patch only\")\n\n\treturn cmd\n}\n\nfunc renderDiff(patch string, color bool) string {\n\tc := patch\n\n\tif color {\n\t\tvar s strings.Builder\n\t\tvar pr strings.Builder\n\n\t\tdiffChroma := &gansi.CodeBlockElement{\n\t\t\tCode:     patch,\n\t\t\tLanguage: \"diff\",\n\t\t}\n\n\t\terr := diffChroma.Render(&pr, common.StyleRenderer())\n\n\t\tif err != nil {\n\t\t\ts.WriteString(fmt.Sprintf(\"\\n%s\", err.Error()))\n\t\t} else {\n\t\t\ts.WriteString(fmt.Sprintf(\"\\n%s\", pr.String()))\n\t\t}\n\n\t\tc = s.String()\n\t}\n\n\treturn c\n}\n\nfunc renderStats(diff *git.Diff, commonStyle *styles.Styles, color bool) string {\n\tstyle := commonStyle.Log\n\tc := diff.Stats().String()\n\n\tif color {\n\t\ts := strings.Split(c, \"\\n\")\n\n\t\tfor i, line := range s {\n\t\t\tch := strings.Split(line, \"|\")\n\t\t\tif len(ch) > 1 {\n\t\t\t\tadddel := ch[len(ch)-1]\n\t\t\t\tadddel = strings.ReplaceAll(adddel, \"+\", style.CommitStatsAdd.Render(\"+\"))\n\t\t\t\tadddel = strings.ReplaceAll(adddel, \"-\", style.CommitStatsDel.Render(\"-\"))\n\t\t\t\ts[i] = strings.Join(ch[:len(ch)-1], \"|\") + \"|\" + adddel\n\t\t\t}\n\t\t}\n\n\t\treturn strings.Join(s, \"\\n\")\n\t}\n\n\treturn c\n}\n"
  },
  {
    "path": "pkg/ssh/cmd/create.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/spf13/cobra\"\n)\n\n// createCommand is the command for creating a new repository.\nfunc createCommand() *cobra.Command {\n\tvar private bool\n\tvar description string\n\tvar projectName string\n\tvar hidden bool\n\n\tcmd := &cobra.Command{\n\t\tUse:               \"create REPOSITORY\",\n\t\tShort:             \"Create a new repository\",\n\t\tArgs:              cobra.ExactArgs(1),\n\t\tPersistentPreRunE: checkIfCollab,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tcfg := config.FromContext(ctx)\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\tuser := proto.UserFromContext(ctx)\n\t\t\tname := args[0]\n\t\t\tr, err := be.CreateRepository(ctx, name, user, proto.RepositoryOptions{\n\t\t\t\tPrivate:     private,\n\t\t\t\tDescription: description,\n\t\t\t\tProjectName: projectName,\n\t\t\t\tHidden:      hidden,\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tcloneurl := fmt.Sprintf(\"%s/%s.git\", cfg.SSH.PublicURL, r.Name())\n\t\t\tcmd.PrintErrf(\"Created repository %s\\n\", r.Name())\n\t\t\tcmd.Println(cloneurl)\n\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tcmd.Flags().BoolVarP(&private, \"private\", \"p\", false, \"make the repository private\")\n\tcmd.Flags().StringVarP(&description, \"description\", \"d\", \"\", \"set the repository description\")\n\tcmd.Flags().StringVarP(&projectName, \"name\", \"n\", \"\", \"set the project name\")\n\tcmd.Flags().BoolVarP(&hidden, \"hidden\", \"H\", false, \"hide the repository from the UI\")\n\n\treturn cmd\n}\n"
  },
  {
    "path": "pkg/ssh/cmd/delete.go",
    "content": "package cmd\n\nimport (\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/spf13/cobra\"\n)\n\nfunc deleteCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:               \"delete REPOSITORY\",\n\t\tAliases:           []string{\"del\", \"remove\", \"rm\"},\n\t\tShort:             \"Delete a repository\",\n\t\tArgs:              cobra.ExactArgs(1),\n\t\tPersistentPreRunE: checkIfReadableAndCollab,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\tname := args[0]\n\n\t\t\treturn be.DeleteRepository(ctx, name)\n\t\t},\n\t}\n\n\treturn cmd\n}\n"
  },
  {
    "path": "pkg/ssh/cmd/description.go",
    "content": "package cmd\n\nimport (\n\t\"strings\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/spf13/cobra\"\n)\n\nfunc descriptionCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:               \"description REPOSITORY [DESCRIPTION]\",\n\t\tAliases:           []string{\"desc\"},\n\t\tShort:             \"Set or get the description for a repository\",\n\t\tArgs:              cobra.MinimumNArgs(1),\n\t\tPersistentPreRunE: checkIfReadable,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\trn := strings.TrimSuffix(args[0], \".git\")\n\t\t\tswitch len(args) {\n\t\t\tcase 1:\n\t\t\t\tdesc, err := be.Description(ctx, rn)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tcmd.Println(desc)\n\t\t\tdefault:\n\t\t\t\tif err := checkIfCollab(cmd, args); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tif err := be.SetDescription(ctx, rn, strings.Join(args[1:], \" \")); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn nil\n\t\t},\n\t}\n\n\treturn cmd\n}\n"
  },
  {
    "path": "pkg/ssh/cmd/git.go",
    "content": "package cmd\n\nimport (\n\t\"errors\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"charm.land/log/v2\"\n\t\"github.com/charmbracelet/soft-serve/pkg/access\"\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/git\"\n\t\"github.com/charmbracelet/soft-serve/pkg/lfs\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/sshutils\"\n\t\"github.com/charmbracelet/soft-serve/pkg/utils\"\n\t\"github.com/prometheus/client_golang/prometheus\"\n\t\"github.com/prometheus/client_golang/prometheus/promauto\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar (\n\tuploadPackCounter = promauto.NewCounterVec(prometheus.CounterOpts{\n\t\tNamespace: \"soft_serve\",\n\t\tSubsystem: \"git\",\n\t\tName:      \"upload_pack_total\",\n\t\tHelp:      \"The total number of git-upload-pack requests\",\n\t}, []string{\"repo\"})\n\n\treceivePackCounter = promauto.NewCounterVec(prometheus.CounterOpts{\n\t\tNamespace: \"soft_serve\",\n\t\tSubsystem: \"git\",\n\t\tName:      \"receive_pack_total\",\n\t\tHelp:      \"The total number of git-receive-pack requests\",\n\t}, []string{\"repo\"})\n\n\tuploadArchiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{\n\t\tNamespace: \"soft_serve\",\n\t\tSubsystem: \"git\",\n\t\tName:      \"upload_archive_total\",\n\t\tHelp:      \"The total number of git-upload-archive requests\",\n\t}, []string{\"repo\"})\n\n\tlfsAuthenticateCounter = promauto.NewCounterVec(prometheus.CounterOpts{\n\t\tNamespace: \"soft_serve\",\n\t\tSubsystem: \"git\",\n\t\tName:      \"lfs_authenticate_total\",\n\t\tHelp:      \"The total number of git-lfs-authenticate requests\",\n\t}, []string{\"repo\", \"operation\"})\n\n\tlfsTransferCounter = promauto.NewCounterVec(prometheus.CounterOpts{\n\t\tNamespace: \"soft_serve\",\n\t\tSubsystem: \"git\",\n\t\tName:      \"lfs_transfer_total\",\n\t\tHelp:      \"The total number of git-lfs-transfer requests\",\n\t}, []string{\"repo\", \"operation\"})\n\n\tuploadPackSeconds = promauto.NewCounterVec(prometheus.CounterOpts{\n\t\tNamespace: \"soft_serve\",\n\t\tSubsystem: \"git\",\n\t\tName:      \"upload_pack_seconds_total\",\n\t\tHelp:      \"The total time spent on git-upload-pack requests\",\n\t}, []string{\"repo\"})\n\n\treceivePackSeconds = promauto.NewCounterVec(prometheus.CounterOpts{\n\t\tNamespace: \"soft_serve\",\n\t\tSubsystem: \"git\",\n\t\tName:      \"receive_pack_seconds_total\",\n\t\tHelp:      \"The total time spent on git-receive-pack requests\",\n\t}, []string{\"repo\"})\n\n\tuploadArchiveSeconds = promauto.NewCounterVec(prometheus.CounterOpts{\n\t\tNamespace: \"soft_serve\",\n\t\tSubsystem: \"git\",\n\t\tName:      \"upload_archive_seconds_total\",\n\t\tHelp:      \"The total time spent on git-upload-archive requests\",\n\t}, []string{\"repo\"})\n\n\tlfsAuthenticateSeconds = promauto.NewCounterVec(prometheus.CounterOpts{\n\t\tNamespace: \"soft_serve\",\n\t\tSubsystem: \"git\",\n\t\tName:      \"lfs_authenticate_seconds_total\",\n\t\tHelp:      \"The total time spent on git-lfs-authenticate requests\",\n\t}, []string{\"repo\", \"operation\"})\n\n\tlfsTransferSeconds = promauto.NewCounterVec(prometheus.CounterOpts{\n\t\tNamespace: \"soft_serve\",\n\t\tSubsystem: \"git\",\n\t\tName:      \"lfs_transfer_seconds_total\",\n\t\tHelp:      \"The total time spent on git-lfs-transfer requests\",\n\t}, []string{\"repo\", \"operation\"})\n\n\tcreateRepoCounter = promauto.NewCounterVec(prometheus.CounterOpts{\n\t\tNamespace: \"soft_serve\",\n\t\tSubsystem: \"ssh\",\n\t\tName:      \"create_repo_total\",\n\t\tHelp:      \"The total number of create repo requests\",\n\t}, []string{\"repo\"})\n)\n\n// GitUploadPackCommand returns a cobra command for git-upload-pack.\nfunc GitUploadPackCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:    \"git-upload-pack REPO\",\n\t\tShort:  \"Git upload pack\",\n\t\tArgs:   cobra.ExactArgs(1),\n\t\tHidden: true,\n\t\tRunE:   gitRunE,\n\t}\n\n\treturn cmd\n}\n\n// GitUploadArchiveCommand returns a cobra command for git-upload-archive.\nfunc GitUploadArchiveCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:    \"git-upload-archive REPO\",\n\t\tShort:  \"Git upload archive\",\n\t\tArgs:   cobra.ExactArgs(1),\n\t\tHidden: true,\n\t\tRunE:   gitRunE,\n\t}\n\n\treturn cmd\n}\n\n// GitReceivePackCommand returns a cobra command for git-receive-pack.\nfunc GitReceivePackCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:    \"git-receive-pack REPO\",\n\t\tShort:  \"Git receive pack\",\n\t\tArgs:   cobra.ExactArgs(1),\n\t\tHidden: true,\n\t\tRunE:   gitRunE,\n\t}\n\n\treturn cmd\n}\n\n// GitLFSAuthenticateCommand returns a cobra command for git-lfs-authenticate.\nfunc GitLFSAuthenticateCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:    \"git-lfs-authenticate REPO OPERATION\",\n\t\tShort:  \"Git LFS authenticate\",\n\t\tArgs:   cobra.ExactArgs(2),\n\t\tHidden: true,\n\t\tRunE:   gitRunE,\n\t}\n\n\treturn cmd\n}\n\n// GitLFSTransfer returns a cobra command for git-lfs-transfer.\nfunc GitLFSTransfer() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:    \"git-lfs-transfer REPO OPERATION\",\n\t\tShort:  \"Git LFS transfer\",\n\t\tArgs:   cobra.ExactArgs(2),\n\t\tHidden: true,\n\t\tRunE:   gitRunE,\n\t}\n\n\treturn cmd\n}\n\nfunc gitRunE(cmd *cobra.Command, args []string) error {\n\tctx := cmd.Context()\n\tcfg := config.FromContext(ctx)\n\tbe := backend.FromContext(ctx)\n\tlogger := log.FromContext(ctx)\n\tstart := time.Now()\n\n\t// repo should be in the form of \"repo.git\"\n\tname := utils.SanitizeRepo(args[0])\n\tpk := sshutils.PublicKeyFromContext(ctx)\n\tak := sshutils.MarshalAuthorizedKey(pk)\n\tuser := proto.UserFromContext(ctx)\n\taccessLevel := be.AccessLevelForUser(ctx, name, user)\n\t// git bare repositories should end in \".git\"\n\t// https://git-scm.com/docs/gitrepository-layout\n\trepoDir := name + \".git\"\n\treposDir := filepath.Join(cfg.DataPath, \"repos\")\n\tif err := git.EnsureWithin(reposDir, repoDir); err != nil {\n\t\treturn err\n\t}\n\n\t// Set repo in context\n\trepo, _ := be.Repository(ctx, name)\n\tctx = proto.WithRepositoryContext(ctx, repo)\n\n\t// Environment variables to pass down to git hooks.\n\tenvs := []string{\n\t\t\"SOFT_SERVE_REPO_NAME=\" + name,\n\t\t\"SOFT_SERVE_REPO_PATH=\" + filepath.Join(reposDir, repoDir),\n\t\t\"SOFT_SERVE_PUBLIC_KEY=\" + ak,\n\t\t\"SOFT_SERVE_LOG_PATH=\" + filepath.Join(cfg.DataPath, \"log\", \"hooks.log\"),\n\t}\n\n\tif user != nil {\n\t\tenvs = append(envs,\n\t\t\t\"SOFT_SERVE_USERNAME=\"+user.Username(),\n\t\t)\n\t}\n\n\tenvs = append(envs, cfg.Environ()...)\n\n\t// Add GIT_PROTOCOL from session.\n\tif sess := sshutils.SessionFromContext(ctx); sess != nil {\n\t\tfor _, env := range sess.Environ() {\n\t\t\tif strings.HasPrefix(env, \"GIT_PROTOCOL=\") {\n\t\t\t\tenvs = append(envs, env)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\trepoPath := filepath.Join(reposDir, repoDir)\n\tservice := git.Service(cmd.Name())\n\tstdin := cmd.InOrStdin()\n\tstdout := cmd.OutOrStdout()\n\tstderr := cmd.ErrOrStderr()\n\tscmd := git.ServiceCommand{\n\t\tStdin:  stdin,\n\t\tStdout: stdout,\n\t\tStderr: stderr,\n\t\tEnv:    envs,\n\t\tDir:    repoPath,\n\t}\n\n\tswitch service {\n\tcase git.ReceivePackService:\n\t\treceivePackCounter.WithLabelValues(name).Inc()\n\t\tdefer func() {\n\t\t\treceivePackSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())\n\t\t}()\n\t\tif accessLevel < access.ReadWriteAccess {\n\t\t\treturn git.ErrNotAuthed\n\t\t}\n\t\tif repo == nil {\n\t\t\tif _, err := be.CreateRepository(ctx, name, user, proto.RepositoryOptions{Private: false}); err != nil {\n\t\t\t\tlog.Errorf(\"failed to create repo: %s\", err)\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tcreateRepoCounter.WithLabelValues(name).Inc()\n\t\t}\n\n\t\tif err := service.Handler(ctx, scmd); err != nil {\n\t\t\tlogger.Error(\"failed to handle git service\", \"service\", service, \"err\", err, \"repo\", name)\n\t\t\tdefer func() {\n\t\t\t\tif repo == nil {\n\t\t\t\t\t// If the repo was created, but the request failed, delete it.\n\t\t\t\t\tbe.DeleteRepository(ctx, name) //nolint: errcheck\n\t\t\t\t}\n\t\t\t}()\n\n\t\t\treturn git.ErrSystemMalfunction\n\t\t}\n\n\t\tif err := git.EnsureDefaultBranch(ctx, scmd.Dir); err != nil {\n\t\t\tlogger.Error(\"failed to ensure default branch\", \"err\", err, \"repo\", name)\n\t\t\treturn git.ErrSystemMalfunction\n\t\t}\n\n\t\treceivePackCounter.WithLabelValues(name).Inc()\n\n\t\treturn nil\n\tcase git.UploadPackService, git.UploadArchiveService:\n\t\tif accessLevel < access.ReadOnlyAccess {\n\t\t\treturn git.ErrNotAuthed\n\t\t}\n\n\t\tif repo == nil {\n\t\t\treturn git.ErrInvalidRepo\n\t\t}\n\n\t\tswitch service {\n\t\tcase git.UploadArchiveService:\n\t\t\tuploadArchiveCounter.WithLabelValues(name).Inc()\n\t\t\tdefer func() {\n\t\t\t\tuploadArchiveSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())\n\t\t\t}()\n\t\tdefault:\n\t\t\tuploadPackCounter.WithLabelValues(name).Inc()\n\t\t\tdefer func() {\n\t\t\t\tuploadPackSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())\n\t\t\t}()\n\t\t}\n\n\t\terr := service.Handler(ctx, scmd)\n\t\tif errors.Is(err, git.ErrInvalidRepo) {\n\t\t\treturn git.ErrInvalidRepo\n\t\t} else if err != nil {\n\t\t\tlogger.Error(\"failed to handle git service\", \"service\", service, \"err\", err, \"repo\", name)\n\t\t\treturn git.ErrSystemMalfunction\n\t\t}\n\n\t\treturn nil\n\tcase git.LFSTransferService, git.LFSAuthenticateService:\n\t\toperation := args[1]\n\t\tswitch operation {\n\t\tcase lfs.OperationDownload:\n\t\t\tif accessLevel < access.ReadOnlyAccess {\n\t\t\t\treturn git.ErrNotAuthed\n\t\t\t}\n\t\tcase lfs.OperationUpload:\n\t\t\tif accessLevel < access.ReadWriteAccess {\n\t\t\t\treturn git.ErrNotAuthed\n\t\t\t}\n\t\tdefault:\n\t\t\treturn git.ErrInvalidRequest\n\t\t}\n\n\t\tif repo == nil {\n\t\t\treturn git.ErrInvalidRepo\n\t\t}\n\n\t\tscmd.Args = []string{\n\t\t\tname,\n\t\t\targs[1],\n\t\t}\n\n\t\tswitch service {\n\t\tcase git.LFSTransferService:\n\t\t\tlfsTransferCounter.WithLabelValues(name, operation).Inc()\n\t\t\tdefer func() {\n\t\t\t\tlfsTransferSeconds.WithLabelValues(name, operation).Add(time.Since(start).Seconds())\n\t\t\t}()\n\t\tdefault:\n\t\t\tlfsAuthenticateCounter.WithLabelValues(name, operation).Inc()\n\t\t\tdefer func() {\n\t\t\t\tlfsAuthenticateSeconds.WithLabelValues(name, operation).Add(time.Since(start).Seconds())\n\t\t\t}()\n\t\t}\n\n\t\tif err := service.Handler(ctx, scmd); err != nil {\n\t\t\tlogger.Error(\"failed to handle lfs service\", \"service\", service, \"err\", err, \"repo\", name)\n\t\t\treturn git.ErrSystemMalfunction\n\t\t}\n\n\t\treturn nil\n\t}\n\n\treturn errors.New(\"unsupported git service\")\n}\n"
  },
  {
    "path": "pkg/ssh/cmd/hidden.go",
    "content": "package cmd\n\nimport (\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/spf13/cobra\"\n)\n\nfunc hiddenCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:               \"hidden REPOSITORY [TRUE|FALSE]\",\n\t\tShort:             \"Hide or unhide a repository\",\n\t\tAliases:           []string{\"hide\"},\n\t\tArgs:              cobra.MinimumNArgs(1),\n\t\tPersistentPreRunE: checkIfReadable,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\trepo := args[0]\n\t\t\tswitch len(args) {\n\t\t\tcase 1:\n\t\t\t\thidden, err := be.IsHidden(ctx, repo)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tcmd.Println(hidden)\n\t\t\tcase 2:\n\t\t\t\tif err := checkIfCollab(cmd, args); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\thidden := args[1] == \"true\"\n\t\t\t\tif err := be.SetHidden(ctx, repo, hidden); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn nil\n\t\t},\n\t}\n\n\treturn cmd\n}\n"
  },
  {
    "path": "pkg/ssh/cmd/import.go",
    "content": "package cmd\n\nimport (\n\t\"errors\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/task\"\n\t\"github.com/spf13/cobra\"\n)\n\n// importCommand is the command for creating a new repository.\nfunc importCommand() *cobra.Command {\n\tvar private bool\n\tvar description string\n\tvar projectName string\n\tvar mirror bool\n\tvar hidden bool\n\tvar lfs bool\n\tvar lfsEndpoint string\n\n\tcmd := &cobra.Command{\n\t\tUse:               \"import REPOSITORY REMOTE\",\n\t\tShort:             \"Import a new repository from remote\",\n\t\tArgs:              cobra.ExactArgs(2),\n\t\tPersistentPreRunE: checkIfCollab,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\tuser := proto.UserFromContext(ctx)\n\t\t\tname := args[0]\n\t\t\tremote := args[1]\n\t\t\tif _, err := be.ImportRepository(ctx, name, user, remote, proto.RepositoryOptions{\n\t\t\t\tPrivate:     private,\n\t\t\t\tDescription: description,\n\t\t\t\tProjectName: projectName,\n\t\t\t\tMirror:      mirror,\n\t\t\t\tHidden:      hidden,\n\t\t\t\tLFS:         lfs,\n\t\t\t\tLFSEndpoint: lfsEndpoint,\n\t\t\t}); err != nil {\n\t\t\t\tif errors.Is(err, task.ErrAlreadyStarted) {\n\t\t\t\t\treturn errors.New(\"import already in progress\")\n\t\t\t\t}\n\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tcmd.Flags().BoolVarP(&lfs, \"lfs\", \"\", false, \"pull Git LFS objects\")\n\tcmd.Flags().StringVarP(&lfsEndpoint, \"lfs-endpoint\", \"\", \"\", \"set the Git LFS endpoint\")\n\tcmd.Flags().BoolVarP(&mirror, \"mirror\", \"m\", false, \"mirror the repository\")\n\tcmd.Flags().BoolVarP(&private, \"private\", \"p\", false, \"make the repository private\")\n\tcmd.Flags().StringVarP(&description, \"description\", \"d\", \"\", \"set the repository description\")\n\tcmd.Flags().StringVarP(&projectName, \"name\", \"n\", \"\", \"set the project name\")\n\tcmd.Flags().BoolVarP(&hidden, \"hidden\", \"H\", false, \"hide the repository from the UI\")\n\n\treturn cmd\n}\n"
  },
  {
    "path": "pkg/ssh/cmd/info.go",
    "content": "package cmd\n\nimport (\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/sshutils\"\n\t\"github.com/spf13/cobra\"\n)\n\n// InfoCommand returns a command that shows the user's info\nfunc InfoCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"info\",\n\t\tShort: \"Show your info\",\n\t\tArgs:  cobra.NoArgs,\n\t\tRunE: func(cmd *cobra.Command, _ []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\tpk := sshutils.PublicKeyFromContext(ctx)\n\t\t\tuser, err := be.UserByPublicKey(ctx, pk)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tcmd.Printf(\"Username: %s\\n\", user.Username())\n\t\t\tcmd.Printf(\"Admin: %t\\n\", user.IsAdmin())\n\t\t\tcmd.Printf(\"Public keys:\\n\")\n\t\t\tfor _, pk := range user.PublicKeys() {\n\t\t\t\tcmd.Printf(\"  %s\\n\", sshutils.MarshalAuthorizedKey(pk))\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t}\n\n\treturn cmd\n}\n"
  },
  {
    "path": "pkg/ssh/cmd/jwt.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/jwk\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/golang-jwt/jwt/v5\"\n\t\"github.com/spf13/cobra\"\n)\n\n// JWTCommand returns a command that generates a JSON Web Token.\nfunc JWTCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"jwt [repository1 repository2...]\",\n\t\tShort: \"Generate a JSON Web Token\",\n\t\tArgs:  cobra.MinimumNArgs(0),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tcfg := config.FromContext(ctx)\n\t\t\tkp, err := jwk.NewPair(cfg)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tuser := proto.UserFromContext(ctx)\n\t\t\tif user == nil {\n\t\t\t\treturn proto.ErrUserNotFound\n\t\t\t}\n\n\t\t\tnow := time.Now()\n\t\t\texpiresAt := now.Add(time.Hour)\n\t\t\tclaims := jwt.RegisteredClaims{\n\t\t\t\tSubject:   fmt.Sprintf(\"%s#%d\", user.Username(), user.ID()),\n\t\t\t\tExpiresAt: jwt.NewNumericDate(expiresAt), // expire in an hour\n\t\t\t\tNotBefore: jwt.NewNumericDate(now),\n\t\t\t\tIssuedAt:  jwt.NewNumericDate(now),\n\t\t\t\tIssuer:    cfg.HTTP.PublicURL,\n\t\t\t\tAudience:  args,\n\t\t\t}\n\n\t\t\ttoken := jwt.NewWithClaims(jwk.SigningMethod, claims)\n\t\t\ttoken.Header[\"kid\"] = kp.JWK().KeyID\n\t\t\tj, err := token.SignedString(kp.PrivateKey())\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tcmd.Println(j)\n\t\t\treturn nil\n\t\t},\n\t}\n\n\treturn cmd\n}\n"
  },
  {
    "path": "pkg/ssh/cmd/list.go",
    "content": "package cmd\n\nimport (\n\t\"github.com/charmbracelet/soft-serve/pkg/access\"\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/sshutils\"\n\t\"github.com/spf13/cobra\"\n)\n\n// listCommand returns a command that list file or directory at path.\nfunc listCommand() *cobra.Command {\n\tvar all bool\n\n\tlistCmd := &cobra.Command{\n\t\tUse:     \"list\",\n\t\tAliases: []string{\"ls\"},\n\t\tShort:   \"List repositories\",\n\t\tArgs:    cobra.NoArgs,\n\t\tRunE: func(cmd *cobra.Command, _ []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\tpk := sshutils.PublicKeyFromContext(ctx)\n\t\t\trepos, err := be.Repositories(ctx)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tfor _, r := range repos {\n\t\t\t\tif be.AccessLevelByPublicKey(ctx, r.Name(), pk) >= access.ReadOnlyAccess {\n\t\t\t\t\tif !r.IsHidden() || all {\n\t\t\t\t\t\tcmd.Println(r.Name())\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tlistCmd.Flags().BoolVarP(&all, \"all\", \"a\", false, \"List all repositories\")\n\n\treturn listCmd\n}\n"
  },
  {
    "path": "pkg/ssh/cmd/mirror.go",
    "content": "package cmd\n\nimport (\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/spf13/cobra\"\n)\n\nfunc mirrorCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:               \"is-mirror REPOSITORY\",\n\t\tShort:             \"Whether a repository is a mirror\",\n\t\tArgs:              cobra.ExactArgs(1),\n\t\tPersistentPreRunE: checkIfReadable,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\trn := args[0]\n\t\t\trr, err := be.Repository(ctx, rn)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tisMirror := rr.IsMirror()\n\t\t\tcmd.Println(isMirror)\n\t\t\treturn nil\n\t\t},\n\t}\n\n\treturn cmd\n}\n"
  },
  {
    "path": "pkg/ssh/cmd/private.go",
    "content": "package cmd\n\nimport (\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/spf13/cobra\"\n)\n\nfunc privateCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:               \"private REPOSITORY [true|false]\",\n\t\tShort:             \"Set or get a repository private property\",\n\t\tArgs:              cobra.RangeArgs(1, 2),\n\t\tPersistentPreRunE: checkIfReadable,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\trn := strings.TrimSuffix(args[0], \".git\")\n\n\t\t\tswitch len(args) {\n\t\t\tcase 1:\n\t\t\t\tisPrivate, err := be.IsPrivate(ctx, rn)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tcmd.Println(isPrivate)\n\t\t\tcase 2:\n\t\t\t\tisPrivate, err := strconv.ParseBool(args[1])\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tif err := checkIfCollab(cmd, args); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tif err := be.SetPrivate(ctx, rn, isPrivate); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t}\n\n\treturn cmd\n}\n"
  },
  {
    "path": "pkg/ssh/cmd/project_name.go",
    "content": "package cmd\n\nimport (\n\t\"strings\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/spf13/cobra\"\n)\n\nfunc projectName() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:               \"project-name REPOSITORY [NAME]\",\n\t\tAliases:           []string{\"project\"},\n\t\tShort:             \"Set or get the project name for a repository\",\n\t\tArgs:              cobra.MinimumNArgs(1),\n\t\tPersistentPreRunE: checkIfReadable,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\trn := strings.TrimSuffix(args[0], \".git\")\n\t\t\tswitch len(args) {\n\t\t\tcase 1:\n\t\t\t\tpn, err := be.ProjectName(ctx, rn)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tcmd.Println(pn)\n\t\t\tdefault:\n\t\t\t\tif err := checkIfCollab(cmd, args); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tif err := be.SetProjectName(ctx, rn, strings.Join(args[1:], \" \")); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn nil\n\t\t},\n\t}\n\n\treturn cmd\n}\n"
  },
  {
    "path": "pkg/ssh/cmd/pubkey.go",
    "content": "package cmd\n\nimport (\n\t\"strings\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/sshutils\"\n\t\"github.com/spf13/cobra\"\n)\n\n// PubkeyCommand returns a command that manages user public keys.\nfunc PubkeyCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:     \"pubkey\",\n\t\tAliases: []string{\"pubkeys\", \"publickey\", \"publickeys\"},\n\t\tShort:   \"Manage your public keys\",\n\t}\n\n\tpubkeyAddCommand := &cobra.Command{\n\t\tUse:   \"add AUTHORIZED_KEY\",\n\t\tShort: \"Add a public key\",\n\t\tArgs:  cobra.MinimumNArgs(1),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\tpk := sshutils.PublicKeyFromContext(ctx)\n\t\t\tuser, err := be.UserByPublicKey(ctx, pk)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tapk, _, err := sshutils.ParseAuthorizedKey(strings.Join(args, \" \"))\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\treturn be.AddPublicKey(ctx, user.Username(), apk)\n\t\t},\n\t}\n\n\tpubkeyRemoveCommand := &cobra.Command{\n\t\tUse:   \"remove AUTHORIZED_KEY\",\n\t\tArgs:  cobra.MinimumNArgs(1),\n\t\tShort: \"Remove a public key\",\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\tpk := sshutils.PublicKeyFromContext(ctx)\n\t\t\tuser, err := be.UserByPublicKey(ctx, pk)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tapk, _, err := sshutils.ParseAuthorizedKey(strings.Join(args, \" \"))\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\treturn be.RemovePublicKey(ctx, user.Username(), apk)\n\t\t},\n\t}\n\n\tpubkeyListCommand := &cobra.Command{\n\t\tUse:     \"list\",\n\t\tAliases: []string{\"ls\"},\n\t\tShort:   \"List public keys\",\n\t\tArgs:    cobra.NoArgs,\n\t\tRunE: func(cmd *cobra.Command, _ []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\tpk := sshutils.PublicKeyFromContext(ctx)\n\t\t\tuser, err := be.UserByPublicKey(ctx, pk)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tpks := user.PublicKeys()\n\t\t\tfor _, pk := range pks {\n\t\t\t\tcmd.Println(sshutils.MarshalAuthorizedKey(pk))\n\t\t\t}\n\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tcmd.AddCommand(\n\t\tpubkeyAddCommand,\n\t\tpubkeyRemoveCommand,\n\t\tpubkeyListCommand,\n\t)\n\n\treturn cmd\n}\n"
  },
  {
    "path": "pkg/ssh/cmd/rename.go",
    "content": "package cmd\n\nimport (\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/spf13/cobra\"\n)\n\nfunc renameCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:               \"rename REPOSITORY NEW_NAME\",\n\t\tAliases:           []string{\"mv\", \"move\"},\n\t\tShort:             \"Rename an existing repository\",\n\t\tArgs:              cobra.ExactArgs(2),\n\t\tPersistentPreRunE: checkIfReadableAndCollab,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\toldName := args[0]\n\t\t\tnewName := args[1]\n\n\t\t\treturn be.RenameRepository(ctx, oldName, newName)\n\t\t},\n\t}\n\n\treturn cmd\n}\n"
  },
  {
    "path": "pkg/ssh/cmd/repo.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/spf13/cobra\"\n)\n\n// RepoCommand returns a command for managing repositories.\nfunc RepoCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:     \"repo\",\n\t\tAliases: []string{\"repos\", \"repository\", \"repositories\"},\n\t\tShort:   \"Manage repositories\",\n\t}\n\n\tcmd.AddCommand(\n\t\tblobCommand(),\n\t\tbranchCommand(),\n\t\tcollabCommand(),\n\t\tcommitCommand(),\n\t\tcreateCommand(),\n\t\tdeleteCommand(),\n\t\tdescriptionCommand(),\n\t\thiddenCommand(),\n\t\timportCommand(),\n\t\tlistCommand(),\n\t\tmirrorCommand(),\n\t\tprivateCommand(),\n\t\tprojectName(),\n\t\trenameCommand(),\n\t\ttagCommand(),\n\t\ttreeCommand(),\n\t\twebhookCommand(),\n\t)\n\n\tcmd.AddCommand(\n\t\t&cobra.Command{\n\t\t\tUse:               \"info REPOSITORY\",\n\t\t\tShort:             \"Get information about a repository\",\n\t\t\tArgs:              cobra.ExactArgs(1),\n\t\t\tPersistentPreRunE: checkIfReadable,\n\t\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\t\tctx := cmd.Context()\n\t\t\t\tbe := backend.FromContext(ctx)\n\t\t\t\trn := args[0]\n\t\t\t\trr, err := be.Repository(ctx, rn)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tr, err := rr.Open()\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\thead, err := r.HEAD()\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tvar owner proto.User\n\t\t\t\tif rr.UserID() > 0 {\n\t\t\t\t\towner, err = be.UserByID(ctx, rr.UserID())\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tbranches, _ := r.Branches()\n\t\t\t\ttags, _ := r.Tags()\n\n\t\t\t\t// project name and description are optional, handle trailing\n\t\t\t\t// whitespace to avoid breaking tests.\n\t\t\t\tcmd.Println(strings.TrimSpace(fmt.Sprint(\"Project Name: \", rr.ProjectName())))\n\t\t\t\tcmd.Println(\"Repository:\", rr.Name())\n\t\t\t\tcmd.Println(strings.TrimSpace(fmt.Sprint(\"Description: \", rr.Description())))\n\t\t\t\tcmd.Println(\"Private:\", rr.IsPrivate())\n\t\t\t\tcmd.Println(\"Hidden:\", rr.IsHidden())\n\t\t\t\tcmd.Println(\"Mirror:\", rr.IsMirror())\n\t\t\t\tif owner != nil {\n\t\t\t\t\tcmd.Println(strings.TrimSpace(fmt.Sprint(\"Owner: \", owner.Username())))\n\t\t\t\t}\n\t\t\t\tcmd.Println(\"Default Branch:\", head.Name().Short())\n\t\t\t\tif len(branches) > 0 {\n\t\t\t\t\tcmd.Println(\"Branches:\")\n\t\t\t\t\tfor _, b := range branches {\n\t\t\t\t\t\tcmd.Println(\"  -\", b)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif len(tags) > 0 {\n\t\t\t\t\tcmd.Println(\"Tags:\")\n\t\t\t\t\tfor _, t := range tags {\n\t\t\t\t\t\tcmd.Println(\"  -\", t)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\treturn nil\n\t\t\t},\n\t\t},\n\t)\n\n\treturn cmd\n}\n"
  },
  {
    "path": "pkg/ssh/cmd/set_username.go",
    "content": "package cmd\n\nimport (\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/sshutils\"\n\t\"github.com/spf13/cobra\"\n)\n\n// SetUsernameCommand returns a command that sets the user's username.\nfunc SetUsernameCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"set-username USERNAME\",\n\t\tShort: \"Set your username\",\n\t\tArgs:  cobra.ExactArgs(1),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\tpk := sshutils.PublicKeyFromContext(ctx)\n\t\t\tuser, err := be.UserByPublicKey(ctx, pk)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\treturn be.SetUsername(ctx, user.Username(), args[0])\n\t\t},\n\t}\n\n\treturn cmd\n}\n"
  },
  {
    "path": "pkg/ssh/cmd/settings.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"strconv\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/access\"\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/spf13/cobra\"\n)\n\n// SettingsCommand returns a command that manages server settings.\nfunc SettingsCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"settings\",\n\t\tShort: \"Manage server settings\",\n\t}\n\n\tcmd.AddCommand(\n\t\t&cobra.Command{\n\t\t\tUse:               \"allow-keyless [true|false]\",\n\t\t\tShort:             \"Set or get allow keyless access to repositories\",\n\t\t\tArgs:              cobra.RangeArgs(0, 1),\n\t\t\tPersistentPreRunE: checkIfAdmin,\n\t\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\t\tctx := cmd.Context()\n\t\t\t\tbe := backend.FromContext(ctx)\n\t\t\t\tswitch len(args) {\n\t\t\t\tcase 0:\n\t\t\t\t\tcmd.Println(be.AllowKeyless(ctx))\n\t\t\t\tcase 1:\n\t\t\t\t\tv, _ := strconv.ParseBool(args[0])\n\t\t\t\t\tif err := be.SetAllowKeyless(ctx, v); err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\treturn nil\n\t\t\t},\n\t\t},\n\t)\n\n\tals := []string{access.NoAccess.String(), access.ReadOnlyAccess.String(), access.ReadWriteAccess.String(), access.AdminAccess.String()}\n\tcmd.AddCommand(\n\t\t&cobra.Command{\n\t\t\tUse:               \"anon-access [ACCESS_LEVEL]\",\n\t\t\tShort:             \"Set or get the default access level for anonymous users\",\n\t\t\tArgs:              cobra.RangeArgs(0, 1),\n\t\t\tValidArgs:         als,\n\t\t\tPersistentPreRunE: checkIfAdmin,\n\t\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\t\tctx := cmd.Context()\n\t\t\t\tbe := backend.FromContext(ctx)\n\t\t\t\tswitch len(args) {\n\t\t\t\tcase 0:\n\t\t\t\t\tcmd.Println(be.AnonAccess(ctx))\n\t\t\t\tcase 1:\n\t\t\t\t\tal := access.ParseAccessLevel(args[0])\n\t\t\t\t\tif al < 0 {\n\t\t\t\t\t\treturn fmt.Errorf(\"invalid access level: %s. Please choose one of the following: %s\", args[0], als)\n\t\t\t\t\t}\n\t\t\t\t\tif err := be.SetAnonAccess(ctx, al); err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\treturn nil\n\t\t\t},\n\t\t},\n\t)\n\n\treturn cmd\n}\n"
  },
  {
    "path": "pkg/ssh/cmd/tag.go",
    "content": "package cmd\n\nimport (\n\t\"strings\"\n\n\t\"charm.land/log/v2\"\n\t\"github.com/charmbracelet/soft-serve/git\"\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/webhook\"\n\t\"github.com/spf13/cobra\"\n)\n\nfunc tagCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"tag\",\n\t\tShort: \"Manage repository tags\",\n\t}\n\n\tcmd.AddCommand(\n\t\ttagListCommand(),\n\t\ttagDeleteCommand(),\n\t)\n\n\treturn cmd\n}\n\nfunc tagListCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:               \"list REPOSITORY\",\n\t\tAliases:           []string{\"ls\"},\n\t\tShort:             \"List repository tags\",\n\t\tArgs:              cobra.ExactArgs(1),\n\t\tPersistentPreRunE: checkIfReadable,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\trn := strings.TrimSuffix(args[0], \".git\")\n\t\t\trr, err := be.Repository(ctx, rn)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tr, err := rr.Open()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\ttags, _ := r.Tags()\n\t\t\tfor _, t := range tags {\n\t\t\t\tcmd.Println(t)\n\t\t\t}\n\n\t\t\treturn nil\n\t\t},\n\t}\n\n\treturn cmd\n}\n\nfunc tagDeleteCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:               \"delete REPOSITORY TAG\",\n\t\tAliases:           []string{\"remove\", \"rm\", \"del\"},\n\t\tShort:             \"Delete a tag\",\n\t\tArgs:              cobra.ExactArgs(2),\n\t\tPersistentPreRunE: checkIfReadableAndCollab,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\trn := strings.TrimSuffix(args[0], \".git\")\n\t\t\trr, err := be.Repository(ctx, rn)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tr, err := rr.Open()\n\t\t\tif err != nil {\n\t\t\t\tlog.Errorf(\"failed to open repo: %s\", err)\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\ttag := args[1]\n\t\t\ttags, _ := r.Tags()\n\t\t\tvar exists bool\n\t\t\tfor _, t := range tags {\n\t\t\t\tif tag == t {\n\t\t\t\t\texists = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif !exists {\n\t\t\t\tlog.Errorf(\"failed to get tag: tag %s does not exist\", tag)\n\t\t\t\treturn git.ErrReferenceNotExist\n\t\t\t}\n\n\t\t\ttagCommit, err := r.TagCommit(tag)\n\t\t\tif err != nil {\n\t\t\t\tlog.Errorf(\"failed to get tag commit: %s\", err)\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif err := r.DeleteTag(tag); err != nil {\n\t\t\t\tlog.Errorf(\"failed to delete tag: %s\", err)\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\twh, err := webhook.NewBranchTagEvent(ctx, proto.UserFromContext(ctx), rr, git.RefsTags+tag, tagCommit.ID.String(), git.ZeroID)\n\t\t\tif err != nil {\n\t\t\t\tlog.Error(\"failed to create branch_tag webhook\", \"err\", err)\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\treturn webhook.SendEvent(ctx, wh)\n\t\t},\n\t}\n\n\treturn cmd\n}\n"
  },
  {
    "path": "pkg/ssh/cmd/token.go",
    "content": "package cmd\n\nimport (\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"charm.land/lipgloss/v2/table\"\n\t\"github.com/caarlos0/duration\"\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/dustin/go-humanize\"\n\t\"github.com/spf13/cobra\"\n)\n\n// TokenCommand returns a command that manages user access tokens.\nfunc TokenCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:     \"token\",\n\t\tAliases: []string{\"access-token\"},\n\t\tShort:   \"Manage access tokens\",\n\t}\n\n\tvar createExpiresIn string\n\tcreateCmd := &cobra.Command{\n\t\tUse:   \"create NAME\",\n\t\tShort: \"Create a new access token\",\n\t\tArgs:  cobra.MinimumNArgs(1),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\tname := strings.Join(args, \" \")\n\n\t\t\tuser := proto.UserFromContext(ctx)\n\t\t\tif user == nil {\n\t\t\t\treturn proto.ErrUserNotFound\n\t\t\t}\n\n\t\t\tvar expiresAt time.Time\n\t\t\tvar expiresIn time.Duration\n\t\t\tif createExpiresIn != \"\" {\n\t\t\t\td, err := duration.Parse(createExpiresIn)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\texpiresIn = d\n\t\t\t\texpiresAt = time.Now().Add(d)\n\t\t\t}\n\n\t\t\ttoken, err := be.CreateAccessToken(ctx, user, name, expiresAt)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tnotice := \"Access token created\"\n\t\t\tif expiresIn != 0 {\n\t\t\t\tnotice += \" (expires in \" + humanize.Time(expiresAt) + \")\"\n\t\t\t}\n\n\t\t\tcmd.PrintErrln(notice)\n\t\t\tcmd.Println(token)\n\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tcreateCmd.Flags().StringVar(&createExpiresIn, \"expires-in\", \"\", \"Token expiration time (e.g. 1y, 3mo, 2w, 5d4h, 1h30m)\")\n\n\tlistCmd := &cobra.Command{\n\t\tUse:     \"list\",\n\t\tAliases: []string{\"ls\"},\n\t\tShort:   \"List access tokens\",\n\t\tArgs:    cobra.NoArgs,\n\t\tRunE: func(cmd *cobra.Command, _ []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\n\t\t\tuser := proto.UserFromContext(ctx)\n\t\t\tif user == nil {\n\t\t\t\treturn proto.ErrUserNotFound\n\t\t\t}\n\n\t\t\ttokens, err := be.ListAccessTokens(ctx, user)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif len(tokens) == 0 {\n\t\t\t\tcmd.Println(\"No tokens found\")\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tnow := time.Now()\n\t\t\ttable := table.New().Headers(\"ID\", \"Name\", \"Created At\", \"Expires In\")\n\t\t\tfor _, token := range tokens {\n\t\t\t\texpiresAt := \"-\"\n\t\t\t\tif !token.ExpiresAt.IsZero() {\n\t\t\t\t\tif now.After(token.ExpiresAt) {\n\t\t\t\t\t\texpiresAt = \"expired\"\n\t\t\t\t\t} else {\n\t\t\t\t\t\texpiresAt = humanize.Time(token.ExpiresAt)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\ttable = table.Row(strconv.FormatInt(token.ID, 10),\n\t\t\t\t\ttoken.Name,\n\t\t\t\t\thumanize.Time(token.CreatedAt),\n\t\t\t\t\texpiresAt,\n\t\t\t\t)\n\t\t\t}\n\t\t\tcmd.Println(table)\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tdeleteCmd := &cobra.Command{\n\t\tUse:     \"delete ID\",\n\t\tAliases: []string{\"rm\", \"remove\"},\n\t\tShort:   \"Delete an access token\",\n\t\tArgs:    cobra.ExactArgs(1),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\n\t\t\tuser := proto.UserFromContext(ctx)\n\t\t\tif user == nil {\n\t\t\t\treturn proto.ErrUserNotFound\n\t\t\t}\n\n\t\t\tid, err := strconv.ParseInt(args[0], 10, 64)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif err := be.DeleteAccessToken(ctx, user, id); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tcmd.PrintErrln(\"Access token deleted\")\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tcmd.AddCommand(\n\t\tcreateCmd,\n\t\tlistCmd,\n\t\tdeleteCmd,\n\t)\n\n\treturn cmd\n}\n"
  },
  {
    "path": "pkg/ssh/cmd/tree.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/charmbracelet/soft-serve/git\"\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/common\"\n\t\"github.com/dustin/go-humanize\"\n\t\"github.com/spf13/cobra\"\n)\n\n// treeCommand returns a command that list file or directory at path.\nfunc treeCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:               \"tree REPOSITORY [REFERENCE] [PATH]\",\n\t\tShort:             \"Print repository tree at path\",\n\t\tArgs:              cobra.RangeArgs(1, 3),\n\t\tPersistentPreRunE: checkIfReadable,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\trn := args[0]\n\t\t\tpath := \"\"\n\t\t\tref := \"\"\n\t\t\tswitch len(args) {\n\t\t\tcase 2:\n\t\t\t\tpath = args[1]\n\t\t\tcase 3:\n\t\t\t\tref = args[1]\n\t\t\t\tpath = args[2]\n\t\t\t}\n\t\t\trr, err := be.Repository(ctx, rn)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tr, err := rr.Open()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif ref == \"\" {\n\t\t\t\thead, err := r.HEAD()\n\t\t\t\tif err != nil {\n\t\t\t\t\tif bs, err := r.Branches(); err != nil && len(bs) == 0 {\n\t\t\t\t\t\treturn fmt.Errorf(\"repository is empty\")\n\t\t\t\t\t}\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tref = head.ID\n\t\t\t}\n\n\t\t\ttree, err := r.LsTree(ref)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tents := git.Entries{}\n\t\t\tif path != \"\" && path != \"/\" {\n\t\t\t\tte, err := tree.TreeEntry(path)\n\t\t\t\tif err == git.ErrRevisionNotExist {\n\t\t\t\t\treturn proto.ErrFileNotFound\n\t\t\t\t}\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tif te.Type() == \"tree\" {\n\t\t\t\t\ttree, err = tree.SubTree(path)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\tents, err = tree.Entries()\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tents = append(ents, te)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tents, err = tree.Entries()\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t\tents.Sort()\n\t\t\tfor _, ent := range ents {\n\t\t\t\tsize := ent.Size()\n\t\t\t\tssize := \"\"\n\t\t\t\tif size == 0 {\n\t\t\t\t\tssize = \"-\"\n\t\t\t\t} else {\n\t\t\t\t\tssize = humanize.Bytes(uint64(size)) //nolint:gosec\n\t\t\t\t}\n\t\t\t\tcmd.Printf(\"%s\\t%s\\t %s\\n\", ent.Mode(), ssize, common.UnquoteFilename(ent.Name()))\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t}\n\treturn cmd\n}\n"
  },
  {
    "path": "pkg/ssh/cmd/user.go",
    "content": "package cmd\n\nimport (\n\t\"sort\"\n\t\"strings\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/sshutils\"\n\t\"github.com/spf13/cobra\"\n\t\"golang.org/x/crypto/ssh\"\n)\n\n// UserCommand returns the user subcommand.\nfunc UserCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:     \"user\",\n\t\tAliases: []string{\"users\"},\n\t\tShort:   \"Manage users\",\n\t}\n\n\tvar admin bool\n\tvar key string\n\tuserCreateCommand := &cobra.Command{\n\t\tUse:               \"create USERNAME\",\n\t\tShort:             \"Create a new user\",\n\t\tArgs:              cobra.ExactArgs(1),\n\t\tPersistentPreRunE: checkIfAdmin,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tvar pubkeys []ssh.PublicKey\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\tusername := args[0]\n\t\t\tif key != \"\" {\n\t\t\t\tpk, _, err := sshutils.ParseAuthorizedKey(key)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tpubkeys = []ssh.PublicKey{pk}\n\t\t\t}\n\n\t\t\topts := proto.UserOptions{\n\t\t\t\tAdmin:      admin,\n\t\t\t\tPublicKeys: pubkeys,\n\t\t\t}\n\n\t\t\t_, err := be.CreateUser(ctx, username, opts)\n\t\t\treturn err\n\t\t},\n\t}\n\n\tuserCreateCommand.Flags().BoolVarP(&admin, \"admin\", \"a\", false, \"make the user an admin\")\n\tuserCreateCommand.Flags().StringVarP(&key, \"key\", \"k\", \"\", \"add a public key to the user\")\n\n\tuserDeleteCommand := &cobra.Command{\n\t\tUse:               \"delete USERNAME\",\n\t\tShort:             \"Delete a user\",\n\t\tArgs:              cobra.ExactArgs(1),\n\t\tPersistentPreRunE: checkIfAdmin,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\tusername := args[0]\n\n\t\t\treturn be.DeleteUser(ctx, username)\n\t\t},\n\t}\n\n\tuserListCommand := &cobra.Command{\n\t\tUse:               \"list\",\n\t\tAliases:           []string{\"ls\"},\n\t\tShort:             \"List users\",\n\t\tArgs:              cobra.NoArgs,\n\t\tPersistentPreRunE: checkIfAdmin,\n\t\tRunE: func(cmd *cobra.Command, _ []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\tusers, err := be.Users(ctx)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tsort.Strings(users)\n\t\t\tfor _, u := range users {\n\t\t\t\tcmd.Println(u)\n\t\t\t}\n\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tuserAddPubkeyCommand := &cobra.Command{\n\t\tUse:               \"add-pubkey USERNAME AUTHORIZED_KEY\",\n\t\tShort:             \"Add a public key to a user\",\n\t\tArgs:              cobra.MinimumNArgs(2),\n\t\tPersistentPreRunE: checkIfAdmin,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\tusername := args[0]\n\t\t\tpubkey := strings.Join(args[1:], \" \")\n\t\t\tpk, _, err := sshutils.ParseAuthorizedKey(pubkey)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\treturn be.AddPublicKey(ctx, username, pk)\n\t\t},\n\t}\n\n\tuserRemovePubkeyCommand := &cobra.Command{\n\t\tUse:               \"remove-pubkey USERNAME AUTHORIZED_KEY\",\n\t\tShort:             \"Remove a public key from a user\",\n\t\tArgs:              cobra.MinimumNArgs(2),\n\t\tPersistentPreRunE: checkIfAdmin,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\tusername := args[0]\n\t\t\tpubkey := strings.Join(args[1:], \" \")\n\t\t\tpk, _, err := sshutils.ParseAuthorizedKey(pubkey)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\treturn be.RemovePublicKey(ctx, username, pk)\n\t\t},\n\t}\n\n\tuserSetAdminCommand := &cobra.Command{\n\t\tUse:               \"set-admin USERNAME [true|false]\",\n\t\tShort:             \"Make a user an admin\",\n\t\tArgs:              cobra.ExactArgs(2),\n\t\tPersistentPreRunE: checkIfAdmin,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\tusername := args[0]\n\n\t\t\treturn be.SetAdmin(ctx, username, args[1] == \"true\")\n\t\t},\n\t}\n\n\tuserInfoCommand := &cobra.Command{\n\t\tUse:               \"info USERNAME\",\n\t\tShort:             \"Show information about a user\",\n\t\tArgs:              cobra.ExactArgs(1),\n\t\tPersistentPreRunE: checkIfAdmin,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\tusername := args[0]\n\n\t\t\tuser, err := be.User(ctx, username)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tisAdmin := user.IsAdmin()\n\n\t\t\tcmd.Printf(\"Username: %s\\n\", user.Username())\n\t\t\tcmd.Printf(\"Admin: %t\\n\", isAdmin)\n\t\t\tcmd.Printf(\"Public keys:\\n\")\n\t\t\tfor _, pk := range user.PublicKeys() {\n\t\t\t\tcmd.Printf(\"  %s\\n\", sshutils.MarshalAuthorizedKey(pk))\n\t\t\t}\n\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tuserSetUsernameCommand := &cobra.Command{\n\t\tUse:               \"set-username USERNAME NEW_USERNAME\",\n\t\tShort:             \"Change a user's username\",\n\t\tArgs:              cobra.ExactArgs(2),\n\t\tPersistentPreRunE: checkIfAdmin,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\tusername := args[0]\n\t\t\tnewUsername := args[1]\n\n\t\t\treturn be.SetUsername(ctx, username, newUsername)\n\t\t},\n\t}\n\n\tcmd.AddCommand(\n\t\tuserCreateCommand,\n\t\tuserAddPubkeyCommand,\n\t\tuserInfoCommand,\n\t\tuserListCommand,\n\t\tuserDeleteCommand,\n\t\tuserRemovePubkeyCommand,\n\t\tuserSetAdminCommand,\n\t\tuserSetUsernameCommand,\n\t)\n\n\treturn cmd\n}\n"
  },
  {
    "path": "pkg/ssh/cmd/webhooks.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"charm.land/lipgloss/v2/table\"\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/utils\"\n\t\"github.com/charmbracelet/soft-serve/pkg/webhook\"\n\t\"github.com/dustin/go-humanize\"\n\t\"github.com/google/uuid\"\n\t\"github.com/spf13/cobra\"\n)\n\nfunc webhookCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:     \"webhook\",\n\t\tAliases: []string{\"webhooks\"},\n\t\tShort:   \"Manage repository webhooks\",\n\t}\n\n\tcmd.AddCommand(\n\t\twebhookListCommand(),\n\t\twebhookCreateCommand(),\n\t\twebhookDeleteCommand(),\n\t\twebhookUpdateCommand(),\n\t\twebhookDeliveriesCommand(),\n\t)\n\n\treturn cmd\n}\n\nvar webhookEvents []string\n\nfunc init() {\n\tevents := webhook.Events()\n\twebhookEvents = make([]string, len(events))\n\tfor i, e := range events {\n\t\twebhookEvents[i] = e.String()\n\t}\n}\n\nfunc webhookListCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:               \"list REPOSITORY\",\n\t\tShort:             \"List repository webhooks\",\n\t\tArgs:              cobra.ExactArgs(1),\n\t\tPersistentPreRunE: checkIfAdmin,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\trepo, err := be.Repository(ctx, args[0])\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\twebhooks, err := be.ListWebhooks(ctx, repo)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\ttable := table.New().Headers(\"ID\", \"URL\", \"Events\", \"Active\", \"Created At\", \"Updated At\")\n\t\t\tfor _, h := range webhooks {\n\t\t\t\tevents := make([]string, len(h.Events))\n\t\t\t\tfor i, e := range h.Events {\n\t\t\t\t\tevents[i] = e.String()\n\t\t\t\t}\n\n\t\t\t\ttable = table.Row(\n\t\t\t\t\tstrconv.FormatInt(h.ID, 10),\n\t\t\t\t\tutils.Sanitize(h.URL),\n\t\t\t\t\tstrings.Join(events, \",\"),\n\t\t\t\t\tstrconv.FormatBool(h.Active),\n\t\t\t\t\thumanize.Time(h.CreatedAt),\n\t\t\t\t\thumanize.Time(h.UpdatedAt),\n\t\t\t\t)\n\t\t\t}\n\t\t\tcmd.Println(table)\n\t\t\treturn nil\n\t\t},\n\t}\n\n\treturn cmd\n}\n\nfunc webhookCreateCommand() *cobra.Command {\n\tvar events []string\n\tvar secret string\n\tvar active bool\n\tvar contentType string\n\tcmd := &cobra.Command{\n\t\tUse:               \"create REPOSITORY URL\",\n\t\tShort:             \"Create a repository webhook\",\n\t\tArgs:              cobra.ExactArgs(2),\n\t\tPersistentPreRunE: checkIfAdmin,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\trepo, err := be.Repository(ctx, args[0])\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tvar evs []webhook.Event\n\t\t\tfor _, e := range events {\n\t\t\t\tev, err := webhook.ParseEvent(e)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"invalid event: %w\", err)\n\t\t\t\t}\n\n\t\t\t\tevs = append(evs, ev)\n\t\t\t}\n\n\t\t\tvar ct webhook.ContentType\n\t\t\tswitch strings.ToLower(strings.TrimSpace(contentType)) {\n\t\t\tcase \"json\":\n\t\t\t\tct = webhook.ContentTypeJSON\n\t\t\tcase \"form\":\n\t\t\t\tct = webhook.ContentTypeForm\n\t\t\tdefault:\n\t\t\t\treturn webhook.ErrInvalidContentType\n\t\t\t}\n\n\t\t\turl := utils.Sanitize(args[1])\n\t\t\treturn be.CreateWebhook(ctx, repo, strings.TrimSpace(url), ct, secret, evs, active)\n\t\t},\n\t}\n\n\tcmd.Flags().StringSliceVarP(&events, \"events\", \"e\", nil, fmt.Sprintf(\"events to trigger the webhook, available events are (%s)\", strings.Join(webhookEvents, \", \")))\n\tcmd.Flags().StringVarP(&secret, \"secret\", \"s\", \"\", \"secret to sign the webhook payload\")\n\tcmd.Flags().BoolVarP(&active, \"active\", \"a\", true, \"whether the webhook is active\")\n\tcmd.Flags().StringVarP(&contentType, \"content-type\", \"c\", \"json\", \"content type of the webhook payload, can be either `json` or `form`\")\n\n\treturn cmd\n}\n\nfunc webhookDeleteCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:               \"delete REPOSITORY WEBHOOK_ID\",\n\t\tShort:             \"Delete a repository webhook\",\n\t\tArgs:              cobra.ExactArgs(2),\n\t\tPersistentPreRunE: checkIfAdmin,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\trepo, err := be.Repository(ctx, args[0])\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tid, err := strconv.ParseInt(args[1], 10, 64)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"invalid webhook ID: %w\", err)\n\t\t\t}\n\n\t\t\treturn be.DeleteWebhook(ctx, repo, id)\n\t\t},\n\t}\n\n\treturn cmd\n}\n\nfunc webhookUpdateCommand() *cobra.Command {\n\tvar events []string\n\tvar secret string\n\tvar active string\n\tvar contentType string\n\tvar url string\n\tcmd := &cobra.Command{\n\t\tUse:               \"update REPOSITORY WEBHOOK_ID\",\n\t\tShort:             \"Update a repository webhook\",\n\t\tArgs:              cobra.ExactArgs(2),\n\t\tPersistentPreRunE: checkIfAdmin,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\trepo, err := be.Repository(ctx, args[0])\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tid, err := strconv.ParseInt(args[1], 10, 64)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"invalid webhook ID: %w\", err)\n\t\t\t}\n\n\t\t\twh, err := be.Webhook(ctx, repo, id)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tnewURL := wh.URL\n\t\t\tif url != \"\" {\n\t\t\t\tnewURL = url\n\t\t\t}\n\n\t\t\tnewSecret := wh.Secret\n\t\t\tif secret != \"\" {\n\t\t\t\tnewSecret = secret\n\t\t\t}\n\n\t\t\tnewActive := wh.Active\n\t\t\tif active != \"\" {\n\t\t\t\tactive, err := strconv.ParseBool(active)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"invalid active value: %w\", err)\n\t\t\t\t}\n\n\t\t\t\tnewActive = active\n\t\t\t}\n\n\t\t\tnewContentType := wh.ContentType\n\t\t\tif contentType != \"\" {\n\t\t\t\tvar ct webhook.ContentType\n\t\t\t\tswitch strings.ToLower(strings.TrimSpace(contentType)) {\n\t\t\t\tcase \"json\":\n\t\t\t\t\tct = webhook.ContentTypeJSON\n\t\t\t\tcase \"form\":\n\t\t\t\t\tct = webhook.ContentTypeForm\n\t\t\t\tdefault:\n\t\t\t\t\treturn webhook.ErrInvalidContentType\n\t\t\t\t}\n\t\t\t\tnewContentType = ct\n\t\t\t}\n\n\t\t\tnewEvents := wh.Events\n\t\t\tif len(events) > 0 {\n\t\t\t\tvar evs []webhook.Event\n\t\t\t\tfor _, e := range events {\n\t\t\t\t\tev, err := webhook.ParseEvent(e)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"invalid event: %w\", err)\n\t\t\t\t\t}\n\n\t\t\t\t\tevs = append(evs, ev)\n\t\t\t\t}\n\n\t\t\t\tnewEvents = evs\n\t\t\t}\n\n\t\t\treturn be.UpdateWebhook(ctx, repo, id, newURL, newContentType, newSecret, newEvents, newActive)\n\t\t},\n\t}\n\n\tcmd.Flags().StringSliceVarP(&events, \"events\", \"e\", nil, fmt.Sprintf(\"events to trigger the webhook, available events are (%s)\", strings.Join(webhookEvents, \", \")))\n\tcmd.Flags().StringVarP(&secret, \"secret\", \"s\", \"\", \"secret to sign the webhook payload\")\n\tcmd.Flags().StringVarP(&active, \"active\", \"a\", \"\", \"whether the webhook is active\")\n\tcmd.Flags().StringVarP(&contentType, \"content-type\", \"c\", \"\", \"content type of the webhook payload, can be either `json` or `form`\")\n\tcmd.Flags().StringVarP(&url, \"url\", \"u\", \"\", \"webhook URL\")\n\n\treturn cmd\n}\n\nfunc webhookDeliveriesCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:     \"deliveries\",\n\t\tShort:   \"Manage webhook deliveries\",\n\t\tAliases: []string{\"delivery\", \"deliver\"},\n\t}\n\n\tcmd.AddCommand(\n\t\twebhookDeliveriesListCommand(),\n\t\twebhookDeliveriesRedeliverCommand(),\n\t\twebhookDeliveriesGetCommand(),\n\t)\n\n\treturn cmd\n}\n\nfunc webhookDeliveriesListCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:               \"list REPOSITORY WEBHOOK_ID\",\n\t\tShort:             \"List webhook deliveries\",\n\t\tArgs:              cobra.ExactArgs(2),\n\t\tPersistentPreRunE: checkIfAdmin,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\tid, err := strconv.ParseInt(args[1], 10, 64)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"invalid webhook ID: %w\", err)\n\t\t\t}\n\n\t\t\tdels, err := be.ListWebhookDeliveries(ctx, id)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\ttable := table.New().Headers(\"Status\", \"ID\", \"Event\", \"Created At\")\n\t\t\tfor _, d := range dels {\n\t\t\t\tstatus := \"❌\"\n\t\t\t\tif d.ResponseStatus >= 200 && d.ResponseStatus < 300 {\n\t\t\t\t\tstatus = \"✅\"\n\t\t\t\t}\n\t\t\t\ttable = table.Row(\n\t\t\t\t\tstatus,\n\t\t\t\t\td.ID.String(),\n\t\t\t\t\td.Event.String(),\n\t\t\t\t\thumanize.Time(d.CreatedAt),\n\t\t\t\t)\n\t\t\t}\n\t\t\tcmd.Println(table)\n\t\t\treturn nil\n\t\t},\n\t}\n\n\treturn cmd\n}\n\nfunc webhookDeliveriesRedeliverCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:               \"redeliver REPOSITORY WEBHOOK_ID DELIVERY_ID\",\n\t\tShort:             \"Redeliver a webhook delivery\",\n\t\tArgs:              cobra.ExactArgs(3),\n\t\tPersistentPreRunE: checkIfAdmin,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\trepo, err := be.Repository(ctx, args[0])\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tid, err := strconv.ParseInt(args[1], 10, 64)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"invalid webhook ID: %w\", err)\n\t\t\t}\n\n\t\t\tdelID, err := uuid.Parse(args[2])\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"invalid delivery ID: %w\", err)\n\t\t\t}\n\n\t\t\treturn be.RedeliverWebhookDelivery(ctx, repo, id, delID)\n\t\t},\n\t}\n\n\treturn cmd\n}\n\nfunc webhookDeliveriesGetCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:               \"get REPOSITORY WEBHOOK_ID DELIVERY_ID\",\n\t\tShort:             \"Get a webhook delivery\",\n\t\tArgs:              cobra.ExactArgs(3),\n\t\tPersistentPreRunE: checkIfAdmin,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tctx := cmd.Context()\n\t\t\tbe := backend.FromContext(ctx)\n\t\t\tid, err := strconv.ParseInt(args[1], 10, 64)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"invalid webhook ID: %w\", err)\n\t\t\t}\n\n\t\t\tdelID, err := uuid.Parse(args[2])\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"invalid delivery ID: %w\", err)\n\t\t\t}\n\n\t\t\tdel, err := be.WebhookDelivery(ctx, id, delID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tout := cmd.OutOrStdout()\n\t\t\tfmt.Fprintf(out, \"ID: %s\\n\", del.ID)                             //nolint:errcheck\n\t\t\tfmt.Fprintf(out, \"Event: %s\\n\", del.Event)                       //nolint:errcheck\n\t\t\tfmt.Fprintf(out, \"Request URL: %s\\n\", del.RequestURL)            //nolint:errcheck\n\t\t\tfmt.Fprintf(out, \"Request Method: %s\\n\", del.RequestMethod)      //nolint:errcheck\n\t\t\tfmt.Fprintf(out, \"Request Error: %s\\n\", del.RequestError.String) //nolint:errcheck\n\t\t\tfmt.Fprintf(out, \"Request Headers:\\n\")                           //nolint:errcheck\n\t\t\treqHeaders := strings.Split(del.RequestHeaders, \"\\n\")\n\t\t\tfor _, h := range reqHeaders {\n\t\t\t\tfmt.Fprintf(out, \"  %s\\n\", h) //nolint:errcheck\n\t\t\t}\n\n\t\t\tfmt.Fprintf(out, \"Request Body:\\n\") //nolint:errcheck\n\t\t\treqBody := strings.Split(del.RequestBody, \"\\n\")\n\t\t\tfor _, b := range reqBody {\n\t\t\t\tfmt.Fprintf(out, \"  %s\\n\", b) //nolint:errcheck\n\t\t\t}\n\n\t\t\tfmt.Fprintf(out, \"Response Status: %d\\n\", del.ResponseStatus) //nolint:errcheck\n\t\t\tfmt.Fprintf(out, \"Response Headers:\\n\")                       //nolint:errcheck\n\t\t\tresHeaders := strings.Split(del.ResponseHeaders, \"\\n\")\n\t\t\tfor _, h := range resHeaders {\n\t\t\t\tfmt.Fprintf(out, \"  %s\\n\", h) //nolint:errcheck\n\t\t\t}\n\n\t\t\tfmt.Fprintf(out, \"Response Body:\\n\") //nolint:errcheck\n\t\t\tresBody := strings.Split(del.ResponseBody, \"\\n\")\n\t\t\tfor _, b := range resBody {\n\t\t\t\tfmt.Fprintf(out, \"  %s\\n\", b) //nolint:errcheck\n\t\t\t}\n\n\t\t\treturn nil\n\t\t},\n\t}\n\n\treturn cmd\n}\n"
  },
  {
    "path": "pkg/ssh/middleware.go",
    "content": "package ssh\n\nimport (\n\t\"fmt\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"charm.land/log/v2\"\n\t\"charm.land/wish/v2\"\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ssh/cmd\"\n\t\"github.com/charmbracelet/soft-serve/pkg/sshutils\"\n\t\"github.com/charmbracelet/soft-serve/pkg/store\"\n\t\"github.com/charmbracelet/ssh\"\n\t\"github.com/prometheus/client_golang/prometheus\"\n\t\"github.com/prometheus/client_golang/prometheus/promauto\"\n\t\"github.com/spf13/cobra\"\n\tgossh \"golang.org/x/crypto/ssh\"\n)\n\n// ErrPermissionDenied is returned when a user is not allowed connect.\nvar ErrPermissionDenied = fmt.Errorf(\"permission denied\")\n\n// AuthenticationMiddleware handles authentication.\nfunc AuthenticationMiddleware(sh ssh.Handler) ssh.Handler {\n\treturn func(s ssh.Session) {\n\t\t// XXX: The authentication key is set in the context but gossh doesn't\n\t\t// validate the authentication. We need to verify that the _last_ key\n\t\t// that was approved is the one that's being used.\n\n\t\tctx := s.Context()\n\t\tbe := backend.FromContext(ctx)\n\n\t\tvar pkFp string\n\t\tperms := s.Permissions().Permissions\n\t\tpk := s.PublicKey()\n\t\tif pk != nil {\n\t\t\t// There is no public key stored in the context, public-key auth\n\t\t\t// was never requested, skip\n\t\t\tif perms == nil {\n\t\t\t\twish.Fatalln(s, ErrPermissionDenied)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tpkFp = gossh.FingerprintSHA256(pk)\n\t\t}\n\n\t\t// Check if the key is the same as the one we have in context\n\t\tfp := perms.Extensions[\"pubkey-fp\"]\n\t\tif fp != \"\" && fp != pkFp {\n\t\t\twish.Fatalln(s, ErrPermissionDenied)\n\t\t\treturn\n\t\t}\n\n\t\tac := be.AllowKeyless(ctx)\n\t\tpublicKeyCounter.WithLabelValues(strconv.FormatBool(ac || pk != nil)).Inc()\n\t\tif !ac && pk == nil {\n\t\t\twish.Fatalln(s, ErrPermissionDenied)\n\t\t\treturn\n\t\t}\n\n\t\t// Set the auth'd user, or anon, in the context\n\t\tvar user proto.User\n\t\tif pk != nil {\n\t\t\tuser, _ = be.UserByPublicKey(ctx, pk)\n\t\t}\n\t\tctx.SetValue(proto.ContextKeyUser, user)\n\n\t\tsh(s)\n\t}\n}\n\n// ContextMiddleware adds the config, backend, and logger to the session context.\nfunc ContextMiddleware(cfg *config.Config, dbx *db.DB, datastore store.Store, be *backend.Backend, logger *log.Logger) func(ssh.Handler) ssh.Handler {\n\treturn func(sh ssh.Handler) ssh.Handler {\n\t\treturn func(s ssh.Session) {\n\t\t\tctx := s.Context()\n\t\t\tctx.SetValue(sshutils.ContextKeySession, s)\n\t\t\tctx.SetValue(config.ContextKey, cfg)\n\t\t\tctx.SetValue(db.ContextKey, dbx)\n\t\t\tctx.SetValue(store.ContextKey, datastore)\n\t\t\tctx.SetValue(backend.ContextKey, be)\n\t\t\tctx.SetValue(log.ContextKey, logger.WithPrefix(\"ssh\"))\n\t\t\tsh(s)\n\t\t}\n\t}\n}\n\nvar cliCommandCounter = promauto.NewCounterVec(prometheus.CounterOpts{\n\tNamespace: \"soft_serve\",\n\tSubsystem: \"cli\",\n\tName:      \"commands_total\",\n\tHelp:      \"Total times each command was called\",\n}, []string{\"command\"})\n\n// CommandMiddleware handles git commands and CLI commands.\n// This middleware must be run after the ContextMiddleware.\nfunc CommandMiddleware(sh ssh.Handler) ssh.Handler {\n\treturn func(s ssh.Session) {\n\t\t_, _, ptyReq := s.Pty()\n\t\tif ptyReq {\n\t\t\tsh(s)\n\t\t\treturn\n\t\t}\n\n\t\tctx := s.Context()\n\t\tcfg := config.FromContext(ctx)\n\n\t\targs := s.Command()\n\t\tcliCommandCounter.WithLabelValues(cmd.CommandName(args)).Inc()\n\t\trootCmd := &cobra.Command{\n\t\t\tShort:        \"Soft Serve is a self-hostable Git server for the command line.\",\n\t\t\tSilenceUsage: true,\n\t\t}\n\t\trootCmd.CompletionOptions.DisableDefaultCmd = true\n\n\t\trootCmd.SetUsageTemplate(cmd.UsageTemplate)\n\t\trootCmd.SetUsageFunc(cmd.UsageFunc)\n\t\trootCmd.AddCommand(\n\t\t\tcmd.GitUploadPackCommand(),\n\t\t\tcmd.GitUploadArchiveCommand(),\n\t\t\tcmd.GitReceivePackCommand(),\n\t\t\tcmd.RepoCommand(),\n\t\t\tcmd.SettingsCommand(),\n\t\t\tcmd.UserCommand(),\n\t\t\tcmd.InfoCommand(),\n\t\t\tcmd.PubkeyCommand(),\n\t\t\tcmd.SetUsernameCommand(),\n\t\t\tcmd.JWTCommand(),\n\t\t\tcmd.TokenCommand(),\n\t\t)\n\n\t\tif cfg.LFS.Enabled {\n\t\t\trootCmd.AddCommand(\n\t\t\t\tcmd.GitLFSAuthenticateCommand(),\n\t\t\t)\n\n\t\t\tif cfg.LFS.SSHEnabled {\n\t\t\t\trootCmd.AddCommand(\n\t\t\t\t\tcmd.GitLFSTransfer(),\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\n\t\trootCmd.SetArgs(args)\n\t\tif len(args) == 0 {\n\t\t\t// otherwise it'll default to os.Args, which is not what we want.\n\t\t\trootCmd.SetArgs([]string{\"--help\"})\n\t\t}\n\t\trootCmd.SetIn(s)\n\t\trootCmd.SetOut(s)\n\t\trootCmd.SetErr(s.Stderr())\n\t\trootCmd.SetContext(ctx)\n\n\t\tif err := rootCmd.ExecuteContext(ctx); err != nil {\n\t\t\ts.Exit(1) //nolint: errcheck\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// LoggingMiddleware logs the ssh connection and command.\nfunc LoggingMiddleware(sh ssh.Handler) ssh.Handler {\n\treturn func(s ssh.Session) {\n\t\tctx := s.Context()\n\t\tlogger := log.FromContext(ctx).WithPrefix(\"ssh\")\n\t\tct := time.Now()\n\t\thpk := sshutils.MarshalAuthorizedKey(s.PublicKey())\n\t\tptyReq, _, isPty := s.Pty()\n\t\taddr := s.RemoteAddr().String()\n\t\tuser := proto.UserFromContext(ctx)\n\t\tlogArgs := []interface{}{\n\t\t\t\"addr\",\n\t\t\taddr,\n\t\t\t\"cmd\",\n\t\t\ts.Command(),\n\t\t}\n\n\t\tif user != nil {\n\t\t\tlogArgs = append([]interface{}{\n\t\t\t\t\"username\",\n\t\t\t\tuser.Username(),\n\t\t\t}, logArgs...)\n\t\t}\n\n\t\tif isPty {\n\t\t\tlogArgs = []interface{}{\n\t\t\t\t\"term\", ptyReq.Term,\n\t\t\t\t\"width\", ptyReq.Window.Width,\n\t\t\t\t\"height\", ptyReq.Window.Height,\n\t\t\t}\n\t\t}\n\n\t\tif config.IsVerbose() {\n\t\t\tlogArgs = append(logArgs,\n\t\t\t\t\"key\", hpk,\n\t\t\t\t\"envs\", s.Environ(),\n\t\t\t)\n\t\t}\n\n\t\tmsg := fmt.Sprintf(\"user %q\", s.User())\n\t\tlogger.Debug(msg+\" connected\", logArgs...)\n\t\tsh(s)\n\t\tlogger.Debug(msg+\" disconnected\", append(logArgs, \"duration\", time.Since(ct))...)\n\t}\n}\n"
  },
  {
    "path": "pkg/ssh/middleware_test.go",
    "content": "package ssh\n\nimport (\n\t\"context\"\n\t\"net\"\n\t\"testing\"\n\n\t\"github.com/charmbracelet/keygen\"\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db/migrate\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/store\"\n\t\"github.com/charmbracelet/soft-serve/pkg/store/database\"\n\t\"github.com/charmbracelet/ssh\"\n\t\"github.com/matryer/is\"\n\tgossh \"golang.org/x/crypto/ssh\"\n\t_ \"modernc.org/sqlite\"\n)\n\n// TestAuthenticationBypass tests for CVE-TBD: Authentication Bypass Vulnerability\n//\n// VULNERABILITY:\n// A critical authentication bypass allows an attacker to impersonate any user\n// (including Admin) by \"offering\" the victim's public key during the SSH handshake\n// before authenticating with their own valid key. This occurs because the user\n// identity is stored in the session context during the \"offer\" phase in\n// PublicKeyHandler and is not properly cleared/validated in AuthenticationMiddleware.\n//\n// This test verifies that:\n// 1. User context is properly set based on the AUTHENTICATED key, not offered keys\n// 2. User context from failed authentication attempts is not preserved\n// 3. Non-admin users cannot gain admin privileges through this attack\nfunc TestAuthenticationBypass(t *testing.T) {\n\tis := is.New(t)\n\tctx := context.Background()\n\n\t// Setup temporary database\n\tdp := t.TempDir()\n\tcfg := config.DefaultConfig()\n\tcfg.DataPath = dp\n\tcfg.DB.Driver = \"sqlite\"\n\tcfg.DB.DataSource = dp + \"/test.db\"\n\n\tctx = config.WithContext(ctx, cfg)\n\tdbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource)\n\tis.NoErr(err)\n\tdefer dbx.Close()\n\n\tis.NoErr(migrate.Migrate(ctx, dbx))\n\tdbstore := database.New(ctx, dbx)\n\tctx = store.WithContext(ctx, dbstore)\n\tbe := backend.New(ctx, cfg, dbx, dbstore)\n\tctx = backend.WithContext(ctx, be)\n\n\t// Generate keys for admin and attacker\n\tadminKeyPath := dp + \"/admin_key\"\n\tadminPair, err := keygen.New(adminKeyPath, keygen.WithKeyType(keygen.Ed25519), keygen.WithWrite())\n\tis.NoErr(err)\n\n\tattackerKeyPath := dp + \"/attacker_key\"\n\tattackerPair, err := keygen.New(attackerKeyPath, keygen.WithKeyType(keygen.Ed25519), keygen.WithWrite())\n\tis.NoErr(err)\n\n\t// Parse public keys\n\tadminPubKey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(adminPair.AuthorizedKey()))\n\tis.NoErr(err)\n\n\tattackerPubKey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(attackerPair.AuthorizedKey()))\n\tis.NoErr(err)\n\n\t// Create admin user\n\tadminUser, err := be.CreateUser(ctx, \"testadmin\", proto.UserOptions{\n\t\tAdmin:      true,\n\t\tPublicKeys: []gossh.PublicKey{adminPubKey},\n\t})\n\tis.NoErr(err)\n\tis.True(adminUser != nil)\n\n\t// Create attacker (non-admin) user\n\tattackerUser, err := be.CreateUser(ctx, \"testattacker\", proto.UserOptions{\n\t\tAdmin:      false,\n\t\tPublicKeys: []gossh.PublicKey{attackerPubKey},\n\t})\n\tis.NoErr(err)\n\tis.True(attackerUser != nil)\n\tis.True(!attackerUser.IsAdmin()) // Verify attacker is NOT admin\n\n\t// Test: Verify that looking up user by key gives correct user\n\tt.Run(\"user_lookup_by_key\", func(t *testing.T) {\n\t\tis := is.New(t)\n\n\t\t// Looking up admin key should return admin user\n\t\tuser, err := be.UserByPublicKey(ctx, adminPubKey)\n\t\tis.NoErr(err)\n\t\tis.Equal(user.Username(), \"testadmin\")\n\t\tis.True(user.IsAdmin())\n\n\t\t// Looking up attacker key should return attacker user\n\t\tuser, err = be.UserByPublicKey(ctx, attackerPubKey)\n\t\tis.NoErr(err)\n\t\tis.Equal(user.Username(), \"testattacker\")\n\t\tis.True(!user.IsAdmin())\n\t})\n\n\t// Test: Simulate the authentication bypass vulnerability\n\t// This test documents the EXPECTED behavior to prevent regression\n\tt.Run(\"authentication_bypass_simulation\", func(t *testing.T) {\n\t\tis := is.New(t)\n\n\t\t// Create a mock context\n\t\tmockCtx := &mockSSHContext{\n\t\t\tContext:     ctx,\n\t\t\tvalues:      make(map[any]any),\n\t\t\tpermissions: &ssh.Permissions{Permissions: &gossh.Permissions{Extensions: make(map[string]string)}},\n\t\t}\n\n\t\t// ATTACK SIMULATION:\n\t\t// Step 1: SSH client offers admin's public key\n\t\t// PublicKeyHandler is called and sets admin user in context\n\t\tmockCtx.SetValue(proto.ContextKeyUser, adminUser)\n\t\tmockCtx.permissions.Extensions[\"pubkey-fp\"] = gossh.FingerprintSHA256(adminPubKey)\n\n\t\t// Step 2: Signature verification FAILS (attacker doesn't have admin's private key)\n\t\t// SSH protocol continues to next key...\n\n\t\t// Step 3: SSH client offers attacker's key (which SUCCEEDS)\n\t\t// PublicKeyHandler is called again, fingerprint is updated\n\t\tmockCtx.permissions.Extensions[\"pubkey-fp\"] = gossh.FingerprintSHA256(attackerPubKey)\n\t\t// BUG: Admin user is STILL in context from step 1!\n\n\t\t// Step 4: AuthenticationMiddleware should re-lookup user based on authenticated key\n\t\t// The middleware MUST NOT trust the user already in context\n\t\tauthenticatedUser, err := be.UserByPublicKey(mockCtx, attackerPubKey)\n\t\tis.NoErr(err)\n\n\t\t// EXPECTED: User should be \"attacker\", NOT \"admin\"\n\t\tis.Equal(authenticatedUser.Username(), \"testattacker\")\n\t\tis.True(!authenticatedUser.IsAdmin())\n\n\t\t// If the vulnerability exists, the context would still have admin user\n\t\tcontextUser := proto.UserFromContext(mockCtx)\n\t\tif contextUser != nil && contextUser.Username() == \"testadmin\" {\n\t\t\tt.Logf(\"WARNING: Context still contains admin user! This indicates the vulnerability exists.\")\n\t\t\tt.Logf(\"The authenticated key is attacker's, but context has admin user.\")\n\t\t}\n\t})\n}\n\n// mockSSHContext implements ssh.Context for testing\ntype mockSSHContext struct {\n\tcontext.Context\n\tvalues      map[any]any\n\tpermissions *ssh.Permissions\n}\n\nfunc (m *mockSSHContext) SetValue(key, value any) {\n\tm.values[key] = value\n}\n\nfunc (m *mockSSHContext) Value(key any) any {\n\tif v, ok := m.values[key]; ok {\n\t\treturn v\n\t}\n\treturn m.Context.Value(key)\n}\n\nfunc (m *mockSSHContext) Permissions() *ssh.Permissions {\n\treturn m.permissions\n}\n\nfunc (m *mockSSHContext) User() string          { return \"\" }\nfunc (m *mockSSHContext) RemoteAddr() net.Addr  { return &net.TCPAddr{} }\nfunc (m *mockSSHContext) LocalAddr() net.Addr   { return &net.TCPAddr{} }\nfunc (m *mockSSHContext) ServerVersion() string { return \"\" }\nfunc (m *mockSSHContext) ClientVersion() string { return \"\" }\nfunc (m *mockSSHContext) SessionID() string     { return \"\" }\nfunc (m *mockSSHContext) Lock()                 {}\nfunc (m *mockSSHContext) Unlock()               {}\n"
  },
  {
    "path": "pkg/ssh/session.go",
    "content": "package ssh\n\nimport (\n\t\"time\"\n\n\ttea \"charm.land/bubbletea/v2\"\n\t\"charm.land/wish/v2\"\n\tbm \"charm.land/wish/v2/bubbletea\"\n\t\"github.com/charmbracelet/soft-serve/pkg/access\"\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/common\"\n\t\"github.com/charmbracelet/ssh\"\n\t\"github.com/prometheus/client_golang/prometheus\"\n\t\"github.com/prometheus/client_golang/prometheus/promauto\"\n)\n\nvar tuiSessionCounter = promauto.NewCounterVec(prometheus.CounterOpts{\n\tNamespace: \"soft_serve\",\n\tSubsystem: \"ssh\",\n\tName:      \"tui_session_total\",\n\tHelp:      \"The total number of TUI sessions\",\n}, []string{\"repo\", \"term\"})\n\nvar tuiSessionDuration = promauto.NewCounterVec(prometheus.CounterOpts{\n\tNamespace: \"soft_serve\",\n\tSubsystem: \"ssh\",\n\tName:      \"tui_session_seconds_total\",\n\tHelp:      \"The total time spent in TUI sessions\",\n}, []string{\"repo\", \"term\"})\n\n// SessionHandler is the soft-serve bubbletea ssh session handler.\n// This middleware must be run after the ContextMiddleware.\nfunc SessionHandler(s ssh.Session) *tea.Program {\n\tpty, _, active := s.Pty()\n\tif !active {\n\t\treturn nil\n\t}\n\n\tctx := s.Context()\n\tbe := backend.FromContext(ctx)\n\tcfg := config.FromContext(ctx)\n\tcmd := s.Command()\n\tinitialRepo := \"\"\n\tif len(cmd) == 1 {\n\t\tinitialRepo = cmd[0]\n\t}\n\n\tauth := be.AccessLevelByPublicKey(ctx, initialRepo, s.PublicKey())\n\tif auth < access.ReadOnlyAccess {\n\t\twish.Fatalln(s, proto.ErrUnauthorized)\n\t\treturn nil\n\t}\n\n\topts := bm.MakeOptions(s)\n\topts = append(opts,\n\t\ttea.WithoutCatchPanics(),\n\t\ttea.WithContext(ctx),\n\t\ttea.WithColorProfile(common.DefaultColorProfile),\n\t)\n\n\tc := common.NewCommon(ctx, pty.Window.Width, pty.Window.Height)\n\tc.SetValue(common.ConfigKey, cfg)\n\tm := NewUI(c, initialRepo)\n\tp := tea.NewProgram(m, opts...)\n\n\ttuiSessionCounter.WithLabelValues(initialRepo, pty.Term).Inc()\n\n\tstart := time.Now()\n\tgo func() {\n\t\t<-ctx.Done()\n\t\ttuiSessionDuration.WithLabelValues(initialRepo, pty.Term).Add(time.Since(start).Seconds())\n\t}()\n\n\treturn p\n}\n"
  },
  {
    "path": "pkg/ssh/session_test.go",
    "content": "package ssh\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"testing\"\n\t\"time\"\n\n\t\"charm.land/log/v2\"\n\tbm \"charm.land/wish/v2/bubbletea\"\n\t\"charm.land/wish/v2/testsession\"\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db/migrate\"\n\t\"github.com/charmbracelet/soft-serve/pkg/store\"\n\t\"github.com/charmbracelet/soft-serve/pkg/store/database\"\n\t\"github.com/charmbracelet/soft-serve/pkg/test\"\n\t\"github.com/charmbracelet/ssh\"\n\t\"github.com/matryer/is\"\n\tgossh \"golang.org/x/crypto/ssh\"\n\t_ \"modernc.org/sqlite\" // sqlite driver\n)\n\nfunc TestSession(t *testing.T) {\n\tis := is.New(t)\n\tt.Run(\"authorized repo access\", func(t *testing.T) {\n\t\tt.Log(\"setting up\")\n\t\ts, close := setup(t)\n\t\ts.Stderr = os.Stderr\n\t\tt.Log(\"requesting pty\")\n\t\terr := s.RequestPty(\"xterm\", 80, 40, nil)\n\t\tis.NoErr(err)\n\t\tgo func() {\n\t\t\ttime.Sleep(1 * time.Second)\n\t\t\t// s.Signal(gossh.SIGTERM)\n\t\t\ts.Close() //nolint: errcheck\n\t\t}()\n\t\tt.Log(\"waiting for session to exit\")\n\t\t_, err = s.Output(\"test\")\n\t\tvar ee *gossh.ExitMissingError\n\t\tis.True(errors.As(err, &ee))\n\t\tt.Log(\"session exited\")\n\t\tis.NoErr(close())\n\t})\n}\n\nfunc setup(tb testing.TB) (*gossh.Session, func() error) {\n\ttb.Helper()\n\tis := is.New(tb)\n\tdp := tb.TempDir()\n\tis.NoErr(os.Setenv(\"SOFT_SERVE_DATA_PATH\", dp))\n\tis.NoErr(os.Setenv(\"SOFT_SERVE_GIT_LISTEN_ADDR\", \":9418\"))\n\tis.NoErr(os.Setenv(\"SOFT_SERVE_SSH_LISTEN_ADDR\", fmt.Sprintf(\":%d\", test.RandomPort())))\n\ttb.Cleanup(func() {\n\t\tis.NoErr(os.Unsetenv(\"SOFT_SERVE_DATA_PATH\"))\n\t\tis.NoErr(os.Unsetenv(\"SOFT_SERVE_GIT_LISTEN_ADDR\"))\n\t\tis.NoErr(os.Unsetenv(\"SOFT_SERVE_SSH_LISTEN_ADDR\"))\n\t\tis.NoErr(os.RemoveAll(dp))\n\t})\n\tctx := context.TODO()\n\tcfg := config.DefaultConfig()\n\tif err := cfg.Validate(); err != nil {\n\t\tlog.Fatal(err)\n\t}\n\tctx = config.WithContext(ctx, cfg)\n\tdbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource)\n\tif err != nil {\n\t\ttb.Fatal(err)\n\t}\n\tif err := migrate.Migrate(ctx, dbx); err != nil {\n\t\ttb.Fatal(err)\n\t}\n\tdbstore := database.New(ctx, dbx)\n\tctx = store.WithContext(ctx, dbstore)\n\tbe := backend.New(ctx, cfg, dbx, dbstore)\n\treturn testsession.New(tb, &ssh.Server{\n\t\tHandler: ContextMiddleware(cfg, dbx, dbstore, be, log.Default())(bm.MiddlewareWithProgramHandler(SessionHandler)(func(s ssh.Session) {\n\t\t\t_, _, active := s.Pty()\n\t\t\tif !active {\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\t\t\ts.Exit(0)\n\t\t})),\n\t}, nil), dbx.Close\n}\n"
  },
  {
    "path": "pkg/ssh/ssh.go",
    "content": "package ssh\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net\"\n\t\"os\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"charm.land/log/v2\"\n\t\"charm.land/wish/v2\"\n\tbm \"charm.land/wish/v2/bubbletea\"\n\trm \"charm.land/wish/v2/recover\"\n\t\"github.com/charmbracelet/keygen\"\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/store\"\n\t\"github.com/charmbracelet/ssh\"\n\t\"github.com/prometheus/client_golang/prometheus\"\n\t\"github.com/prometheus/client_golang/prometheus/promauto\"\n\tgossh \"golang.org/x/crypto/ssh\"\n)\n\nvar (\n\tpublicKeyCounter = promauto.NewCounterVec(prometheus.CounterOpts{\n\t\tNamespace: \"soft_serve\",\n\t\tSubsystem: \"ssh\",\n\t\tName:      \"public_key_auth_total\",\n\t\tHelp:      \"The total number of public key auth requests\",\n\t}, []string{\"allowed\"})\n\n\tkeyboardInteractiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{\n\t\tNamespace: \"soft_serve\",\n\t\tSubsystem: \"ssh\",\n\t\tName:      \"keyboard_interactive_auth_total\",\n\t\tHelp:      \"The total number of keyboard interactive auth requests\",\n\t}, []string{\"allowed\"})\n)\n\n// SSHServer is a SSH server that implements the git protocol.\ntype SSHServer struct { //nolint: revive\n\tsrv    *ssh.Server\n\tcfg    *config.Config\n\tbe     *backend.Backend\n\tctx    context.Context\n\tlogger *log.Logger\n}\n\n// NewSSHServer returns a new SSHServer.\nfunc NewSSHServer(ctx context.Context) (*SSHServer, error) {\n\tcfg := config.FromContext(ctx)\n\tlogger := log.FromContext(ctx).WithPrefix(\"ssh\")\n\tdbx := db.FromContext(ctx)\n\tdatastore := store.FromContext(ctx)\n\tbe := backend.FromContext(ctx)\n\n\tvar err error\n\ts := &SSHServer{\n\t\tcfg:    cfg,\n\t\tctx:    ctx,\n\t\tbe:     be,\n\t\tlogger: logger,\n\t}\n\n\tmw := []wish.Middleware{\n\t\trm.MiddlewareWithLogger(\n\t\t\tlogger,\n\t\t\t// BubbleTea middleware.\n\t\t\tbm.MiddlewareWithProgramHandler(SessionHandler),\n\t\t\t// CLI middleware.\n\t\t\tCommandMiddleware,\n\t\t\t// Logging middleware.\n\t\t\tLoggingMiddleware,\n\t\t\t// Authentication middleware.\n\t\t\t// gossh.PublicKeyHandler doesn't guarantee that the public key\n\t\t\t// is in fact the one used for authentication, so we need to\n\t\t\t// check it again here.\n\t\t\tAuthenticationMiddleware,\n\t\t\t// Context middleware.\n\t\t\t// This must come first to set up the context.\n\t\t\tContextMiddleware(cfg, dbx, datastore, be, logger),\n\t\t),\n\t}\n\n\topts := []ssh.Option{\n\t\tssh.PublicKeyAuth(s.PublicKeyHandler),\n\t\tssh.KeyboardInteractiveAuth(s.KeyboardInteractiveHandler),\n\t\twish.WithAddress(cfg.SSH.ListenAddr),\n\t\twish.WithHostKeyPath(cfg.SSH.KeyPath),\n\t\twish.WithMiddleware(mw...),\n\t}\n\n\t// TODO: Support a real PTY in future version.\n\topts = append(opts, ssh.EmulatePty())\n\n\ts.srv, err = wish.NewServer(opts...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif config.IsDebug() {\n\t\ts.srv.ServerConfigCallback = func(_ ssh.Context) *gossh.ServerConfig {\n\t\t\treturn &gossh.ServerConfig{\n\t\t\t\tAuthLogCallback: func(conn gossh.ConnMetadata, method string, err error) {\n\t\t\t\t\tlogger.Debug(\"authentication\", \"user\", conn.User(), \"method\", method, \"err\", err)\n\t\t\t\t},\n\t\t\t}\n\t\t}\n\t}\n\n\tif cfg.SSH.MaxTimeout > 0 {\n\t\ts.srv.MaxTimeout = time.Duration(cfg.SSH.MaxTimeout) * time.Second\n\t}\n\n\tif cfg.SSH.IdleTimeout > 0 {\n\t\ts.srv.IdleTimeout = time.Duration(cfg.SSH.IdleTimeout) * time.Second\n\t}\n\n\t// Create client ssh key\n\tif _, err := os.Stat(cfg.SSH.ClientKeyPath); err != nil && os.IsNotExist(err) {\n\t\t_, err := keygen.New(cfg.SSH.ClientKeyPath, keygen.WithKeyType(keygen.Ed25519), keygen.WithWrite())\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"client ssh key: %w\", err)\n\t\t}\n\t}\n\n\treturn s, nil\n}\n\n// ListenAndServe starts the SSH server.\nfunc (s *SSHServer) ListenAndServe() error {\n\treturn s.srv.ListenAndServe()\n}\n\n// Serve starts the SSH server on the given net.Listener.\nfunc (s *SSHServer) Serve(l net.Listener) error {\n\treturn s.srv.Serve(l)\n}\n\n// Close closes the SSH server.\nfunc (s *SSHServer) Close() error {\n\treturn s.srv.Close()\n}\n\n// Shutdown gracefully shuts down the SSH server.\nfunc (s *SSHServer) Shutdown(ctx context.Context) error {\n\treturn s.srv.Shutdown(ctx)\n}\n\nfunc initializePermissions(ctx ssh.Context) {\n\tperms := ctx.Permissions()\n\tif perms == nil || perms.Permissions == nil {\n\t\tperms = &ssh.Permissions{Permissions: &gossh.Permissions{}}\n\t}\n\tif perms.Extensions == nil {\n\t\tperms.Extensions = make(map[string]string)\n\t}\n}\n\n// PublicKeyHandler handles public key authentication.\nfunc (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) (allowed bool) {\n\tif pk == nil {\n\t\treturn false\n\t}\n\n\tallowed = true\n\n\t// XXX: store the first \"approved\" public-key fingerprint in the\n\t// permissions block to use for authentication later.\n\tinitializePermissions(ctx)\n\tperms := ctx.Permissions()\n\n\t// Set the public key fingerprint to be used for authentication.\n\tperms.Extensions[\"pubkey-fp\"] = gossh.FingerprintSHA256(pk)\n\tctx.SetValue(ssh.ContextKeyPermissions, perms)\n\n\treturn\n}\n\n// KeyboardInteractiveHandler handles keyboard interactive authentication.\n// This is used after all public key authentication has failed.\nfunc (s *SSHServer) KeyboardInteractiveHandler(ctx ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool {\n\tac := s.be.AllowKeyless(ctx)\n\tkeyboardInteractiveCounter.WithLabelValues(strconv.FormatBool(ac)).Inc()\n\n\t// If we're allowing keyless access, reset the public key fingerprint\n\tinitializePermissions(ctx)\n\tperms := ctx.Permissions()\n\n\tif ac {\n\t\t// XXX: reset the public-key fingerprint. This is used to validate the\n\t\t// public key being used to authenticate.\n\t\tperms.Extensions[\"pubkey-fp\"] = \"\"\n\t\tctx.SetValue(ssh.ContextKeyPermissions, perms)\n\t}\n\treturn ac\n}\n"
  },
  {
    "path": "pkg/ssh/ui.go",
    "content": "package ssh\n\nimport (\n\t\"errors\"\n\n\t\"charm.land/bubbles/v2/key\"\n\t\"charm.land/bubbles/v2/list\"\n\ttea \"charm.land/bubbletea/v2\"\n\t\"charm.land/lipgloss/v2\"\n\t\"github.com/charmbracelet/soft-serve/git\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/common\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/components/footer\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/components/header\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/components/selector\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/pages/repo\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/pages/selection\"\n)\n\ntype page int\n\nconst (\n\tselectionPage page = iota\n\trepoPage\n)\n\ntype sessionState int\n\nconst (\n\tloadingState sessionState = iota\n\terrorState\n\treadyState\n)\n\n// UI is the main UI model.\ntype UI struct {\n\tserverName  string\n\tinitialRepo string\n\tcommon      common.Common\n\tpages       []common.Component\n\tactivePage  page\n\tstate       sessionState\n\theader      *header.Header\n\tfooter      *footer.Footer\n\tshowFooter  bool\n\terror       error\n}\n\n// NewUI returns a new UI model.\nfunc NewUI(c common.Common, initialRepo string) *UI {\n\tserverName := c.Config().Name\n\th := header.New(c, serverName)\n\tui := &UI{\n\t\tserverName:  serverName,\n\t\tcommon:      c,\n\t\tpages:       make([]common.Component, 2), // selection & repo\n\t\tactivePage:  selectionPage,\n\t\tstate:       loadingState,\n\t\theader:      h,\n\t\tinitialRepo: initialRepo,\n\t\tshowFooter:  true,\n\t}\n\tui.footer = footer.New(c, ui)\n\treturn ui\n}\n\nfunc (ui *UI) getMargins() (wm, hm int) {\n\tstyle := ui.common.Styles.App\n\tswitch ui.activePage {\n\tcase selectionPage:\n\t\thm += ui.common.Styles.ServerName.GetHeight() +\n\t\t\tui.common.Styles.ServerName.GetVerticalFrameSize()\n\tcase repoPage:\n\t}\n\twm += style.GetHorizontalFrameSize()\n\thm += style.GetVerticalFrameSize()\n\tif ui.showFooter {\n\t\t// NOTE: we don't use the footer's style to determine the margins\n\t\t// because footer.Height() is the height of the footer after applying\n\t\t// the styles.\n\t\thm += ui.footer.Height()\n\t}\n\treturn\n}\n\n// ShortHelp implements help.KeyMap.\nfunc (ui *UI) ShortHelp() []key.Binding {\n\tb := make([]key.Binding, 0)\n\tswitch ui.state {\n\tcase errorState:\n\t\tb = append(b, ui.common.KeyMap.Back)\n\tcase readyState:\n\t\tb = append(b, ui.pages[ui.activePage].ShortHelp()...)\n\t}\n\tif !ui.IsFiltering() {\n\t\tb = append(b, ui.common.KeyMap.Quit)\n\t}\n\tb = append(b, ui.common.KeyMap.Help)\n\treturn b\n}\n\n// FullHelp implements help.KeyMap.\nfunc (ui *UI) FullHelp() [][]key.Binding {\n\tb := make([][]key.Binding, 0)\n\tswitch ui.state {\n\tcase errorState:\n\t\tb = append(b, []key.Binding{ui.common.KeyMap.Back})\n\tcase readyState:\n\t\tb = append(b, ui.pages[ui.activePage].FullHelp()...)\n\t}\n\th := []key.Binding{\n\t\tui.common.KeyMap.Help,\n\t}\n\tif !ui.IsFiltering() {\n\t\th = append(h, ui.common.KeyMap.Quit)\n\t}\n\tb = append(b, h)\n\treturn b\n}\n\n// SetSize implements common.Component.\nfunc (ui *UI) SetSize(width, height int) {\n\tui.common.SetSize(width, height)\n\twm, hm := ui.getMargins()\n\tui.header.SetSize(width-wm, height-hm)\n\tui.footer.SetSize(width-wm, height-hm)\n\tfor _, p := range ui.pages {\n\t\tif p != nil {\n\t\t\tp.SetSize(width-wm, height-hm)\n\t\t}\n\t}\n}\n\n// Init implements tea.Model.\nfunc (ui *UI) Init() tea.Cmd {\n\tui.pages[selectionPage] = selection.New(ui.common)\n\tui.pages[repoPage] = repo.New(ui.common,\n\t\trepo.NewReadme(ui.common),\n\t\trepo.NewFiles(ui.common),\n\t\trepo.NewLog(ui.common),\n\t\trepo.NewRefs(ui.common, git.RefsHeads),\n\t\trepo.NewRefs(ui.common, git.RefsTags),\n\t)\n\tui.SetSize(ui.common.Width, ui.common.Height)\n\tcmds := make([]tea.Cmd, 0)\n\tcmds = append(cmds,\n\t\tui.pages[selectionPage].Init(),\n\t\tui.pages[repoPage].Init(),\n\t)\n\tif ui.initialRepo != \"\" {\n\t\tcmds = append(cmds, ui.initialRepoCmd(ui.initialRepo))\n\t}\n\tui.state = readyState\n\tui.SetSize(ui.common.Width, ui.common.Height)\n\treturn tea.Batch(cmds...)\n}\n\n// IsFiltering returns true if the selection page is filtering.\nfunc (ui *UI) IsFiltering() bool {\n\tif ui.activePage == selectionPage {\n\t\tif s, ok := ui.pages[selectionPage].(*selection.Selection); ok && s.FilterState() == list.Filtering {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// Update implements tea.Model.\nfunc (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {\n\tui.common.Logger.Debugf(\"msg received: %T\", msg)\n\tcmds := make([]tea.Cmd, 0)\n\tswitch msg := msg.(type) {\n\tcase tea.WindowSizeMsg:\n\t\tui.SetSize(msg.Width, msg.Height)\n\t\tfor i, p := range ui.pages {\n\t\t\tm, cmd := p.Update(msg)\n\t\t\tui.pages[i] = m.(common.Component)\n\t\t\tif cmd != nil {\n\t\t\t\tcmds = append(cmds, cmd)\n\t\t\t}\n\t\t}\n\tcase tea.KeyPressMsg:\n\t\tswitch {\n\t\tcase key.Matches(msg, ui.common.KeyMap.Back) && ui.error != nil:\n\t\t\tui.error = nil\n\t\t\tui.state = readyState\n\t\t\t// Always show the footer on error.\n\t\t\tui.showFooter = ui.footer.ShowAll()\n\t\tcase key.Matches(msg, ui.common.KeyMap.Help):\n\t\t\tcmds = append(cmds, footer.ToggleFooterCmd)\n\t\tcase key.Matches(msg, ui.common.KeyMap.Quit):\n\t\t\tif !ui.IsFiltering() {\n\t\t\t\t// Stop bubblezone background workers.\n\t\t\t\tui.common.Zone.Close()\n\t\t\t\treturn ui, tea.Quit\n\t\t\t}\n\t\tcase ui.activePage == repoPage &&\n\t\t\tui.pages[ui.activePage].(*repo.Repo).Path() == \"\" &&\n\t\t\tkey.Matches(msg, ui.common.KeyMap.Back):\n\t\t\tui.activePage = selectionPage\n\t\t\t// Always show the footer on selection page.\n\t\t\tui.showFooter = true\n\t\t}\n\tcase tea.MouseClickMsg:\n\t\tswitch msg.Button {\n\t\tcase tea.MouseLeft:\n\t\t\tswitch {\n\t\t\tcase ui.common.Zone.Get(\"footer\").InBounds(msg):\n\t\t\t\tcmds = append(cmds, footer.ToggleFooterCmd)\n\t\t\t}\n\t\t}\n\tcase footer.ToggleFooterMsg:\n\t\tui.footer.SetShowAll(!ui.footer.ShowAll())\n\t\t// Show the footer when on repo page and shot all help.\n\t\tif ui.error == nil && ui.activePage == repoPage {\n\t\t\tui.showFooter = !ui.showFooter\n\t\t}\n\tcase repo.RepoMsg:\n\t\tui.common.SetValue(common.RepoKey, msg)\n\t\tui.activePage = repoPage\n\t\t// Show the footer on repo page if show all is set.\n\t\tui.showFooter = ui.footer.ShowAll()\n\t\tcmds = append(cmds, repo.UpdateRefCmd(msg))\n\tcase common.ErrorMsg:\n\t\tui.error = msg\n\t\tui.state = errorState\n\t\tui.showFooter = true\n\tcase selector.SelectMsg:\n\t\tswitch msg.IdentifiableItem.(type) {\n\t\tcase selection.Item:\n\t\t\tif ui.activePage == selectionPage {\n\t\t\t\tcmds = append(cmds, ui.setRepoCmd(msg.ID()))\n\t\t\t}\n\t\t}\n\t}\n\th, cmd := ui.header.Update(msg)\n\tui.header = h.(*header.Header)\n\tif cmd != nil {\n\t\tcmds = append(cmds, cmd)\n\t}\n\tf, cmd := ui.footer.Update(msg)\n\tui.footer = f.(*footer.Footer)\n\tif cmd != nil {\n\t\tcmds = append(cmds, cmd)\n\t}\n\tif ui.state != loadingState {\n\t\tm, cmd := ui.pages[ui.activePage].Update(msg)\n\t\tui.pages[ui.activePage] = m.(common.Component)\n\t\tif cmd != nil {\n\t\t\tcmds = append(cmds, cmd)\n\t\t}\n\t}\n\t// This fixes determining the height margin of the footer.\n\tui.SetSize(ui.common.Width, ui.common.Height)\n\treturn ui, tea.Batch(cmds...)\n}\n\n// View implements tea.Model.\nfunc (ui *UI) View() tea.View {\n\tvar v tea.View\n\tv.AltScreen = true\n\tv.MouseMode = tea.MouseModeCellMotion\n\n\tvar view string\n\twm, hm := ui.getMargins()\n\tswitch ui.state {\n\tcase loadingState:\n\t\tview = \"Loading...\"\n\tcase errorState:\n\t\terr := ui.common.Styles.ErrorTitle.Render(\"Bummer\")\n\t\terr += ui.common.Styles.ErrorBody.Render(ui.error.Error())\n\t\tview = ui.common.Styles.Error.\n\t\t\tWidth(ui.common.Width -\n\t\t\t\twm -\n\t\t\t\tui.common.Styles.ErrorBody.GetHorizontalFrameSize()).\n\t\t\tHeight(ui.common.Height -\n\t\t\t\thm -\n\t\t\t\tui.common.Styles.Error.GetVerticalFrameSize()).\n\t\t\tRender(err)\n\tcase readyState:\n\t\tview = ui.pages[ui.activePage].View()\n\tdefault:\n\t\tview = \"Unknown state :/ this is a bug!\"\n\t}\n\tif ui.activePage == selectionPage {\n\t\tview = lipgloss.JoinVertical(lipgloss.Left, ui.header.View(), view)\n\t}\n\tif ui.showFooter {\n\t\tview = lipgloss.JoinVertical(lipgloss.Left, view, ui.footer.View())\n\t}\n\tv.Content = ui.common.Zone.Scan(\n\t\tui.common.Styles.App.Render(view),\n\t)\n\treturn v\n}\n\nfunc (ui *UI) openRepo(rn string) (proto.Repository, error) {\n\tcfg := ui.common.Config()\n\tif cfg == nil {\n\t\treturn nil, errors.New(\"config is nil\")\n\t}\n\n\tctx := ui.common.Context()\n\tbe := ui.common.Backend()\n\trepos, err := be.Repositories(ctx)\n\tif err != nil {\n\t\tui.common.Logger.Debugf(\"ui: failed to list repos: %v\", err)\n\t\treturn nil, err\n\t}\n\tfor _, r := range repos {\n\t\tif r.Name() == rn {\n\t\t\treturn r, nil\n\t\t}\n\t}\n\treturn nil, common.ErrMissingRepo\n}\n\nfunc (ui *UI) setRepoCmd(rn string) tea.Cmd {\n\treturn func() tea.Msg {\n\t\tr, err := ui.openRepo(rn)\n\t\tif err != nil {\n\t\t\treturn common.ErrorMsg(err)\n\t\t}\n\t\treturn repo.RepoMsg(r)\n\t}\n}\n\nfunc (ui *UI) initialRepoCmd(rn string) tea.Cmd {\n\treturn func() tea.Msg {\n\t\tr, err := ui.openRepo(rn)\n\t\tif err != nil {\n\t\t\treturn nil\n\t\t}\n\t\treturn repo.RepoMsg(r)\n\t}\n}\n"
  },
  {
    "path": "pkg/sshutils/utils.go",
    "content": "package sshutils\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\n\t\"github.com/charmbracelet/ssh\"\n\tgossh \"golang.org/x/crypto/ssh\"\n)\n\n// ParseAuthorizedKey parses an authorized key string into a public key.\nfunc ParseAuthorizedKey(ak string) (gossh.PublicKey, string, error) {\n\tpk, c, _, _, err := gossh.ParseAuthorizedKey([]byte(ak))\n\treturn pk, c, err\n}\n\n// MarshalAuthorizedKey marshals a public key into an authorized key string.\n//\n// This is the inverse of ParseAuthorizedKey.\n// This function is a copy of ssh.MarshalAuthorizedKey, but without the trailing newline.\n// It returns an empty string if pk is nil.\nfunc MarshalAuthorizedKey(pk gossh.PublicKey) string {\n\tif pk == nil {\n\t\treturn \"\"\n\t}\n\treturn string(bytes.TrimSuffix(gossh.MarshalAuthorizedKey(pk), []byte(\"\\n\")))\n}\n\n// KeysEqual returns whether the two public keys are equal.\nfunc KeysEqual(a, b gossh.PublicKey) bool {\n\treturn ssh.KeysEqual(a, b)\n}\n\n// PublicKeyFromContext returns the public key from the context.\nfunc PublicKeyFromContext(ctx context.Context) gossh.PublicKey {\n\tif pk, ok := ctx.Value(ssh.ContextKeyPublicKey).(gossh.PublicKey); ok {\n\t\treturn pk\n\t}\n\treturn nil\n}\n\n// ContextKeySession is the context key for the SSH session.\nvar ContextKeySession = &struct{ string }{\"session\"}\n\n// SessionFromContext returns the SSH session from the context.\nfunc SessionFromContext(ctx context.Context) ssh.Session {\n\tif s, ok := ctx.Value(ContextKeySession).(ssh.Session); ok {\n\t\treturn s\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/sshutils/utils_test.go",
    "content": "package sshutils\n\nimport (\n\t\"testing\"\n\n\t\"github.com/charmbracelet/keygen\"\n\t\"golang.org/x/crypto/ssh\"\n)\n\nfunc generateKeys(tb testing.TB) (*keygen.SSHKeyPair, *keygen.SSHKeyPair) {\n\tgoodKey1, err := keygen.New(\"\", keygen.WithKeyType(keygen.Ed25519))\n\tif err != nil {\n\t\ttb.Fatal(err)\n\t}\n\tgoodKey2, err := keygen.New(\"\", keygen.WithKeyType(keygen.RSA))\n\tif err != nil {\n\t\ttb.Fatal(err)\n\t}\n\n\treturn goodKey1, goodKey2\n}\n\nfunc TestParseAuthorizedKey(t *testing.T) {\n\tgoodKey1, goodKey2 := generateKeys(t)\n\tcases := []struct {\n\t\tin   string\n\t\tgood bool\n\t}{\n\t\t{\n\t\t\tgoodKey1.AuthorizedKey(),\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\tgoodKey2.AuthorizedKey(),\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\tgoodKey1.AuthorizedKey() + \"test\",\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\tgoodKey2.AuthorizedKey() + \"bad\",\n\t\t\tfalse,\n\t\t},\n\t}\n\tfor _, c := range cases {\n\t\t_, _, err := ParseAuthorizedKey(c.in)\n\t\tif c.good && err != nil {\n\t\t\tt.Errorf(\"ParseAuthorizedKey(%q) returned error: %v\", c.in, err)\n\t\t}\n\t\tif !c.good && err == nil {\n\t\t\tt.Errorf(\"ParseAuthorizedKey(%q) did not return error\", c.in)\n\t\t}\n\t}\n}\n\nfunc TestMarshalAuthorizedKey(t *testing.T) {\n\tgoodKey1, goodKey2 := generateKeys(t)\n\tcases := []struct {\n\t\tin       ssh.PublicKey\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tgoodKey1.PublicKey(),\n\t\t\tgoodKey1.AuthorizedKey(),\n\t\t},\n\t\t{\n\t\t\tgoodKey2.PublicKey(),\n\t\t\tgoodKey2.AuthorizedKey(),\n\t\t},\n\t\t{\n\t\t\tnil,\n\t\t\t\"\",\n\t\t},\n\t}\n\tfor _, c := range cases {\n\t\tout := MarshalAuthorizedKey(c.in)\n\t\tif out != c.expected {\n\t\t\tt.Errorf(\"MarshalAuthorizedKey(%v) returned %q, expected %q\", c.in, out, c.expected)\n\t\t}\n\t}\n}\n\nfunc TestKeysEqual(t *testing.T) {\n\tgoodKey1, goodKey2 := generateKeys(t)\n\tcases := []struct {\n\t\tin1      ssh.PublicKey\n\t\tin2      ssh.PublicKey\n\t\texpected bool\n\t}{\n\t\t{\n\t\t\tgoodKey1.PublicKey(),\n\t\t\tgoodKey1.PublicKey(),\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\tgoodKey2.PublicKey(),\n\t\t\tgoodKey2.PublicKey(),\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\tgoodKey1.PublicKey(),\n\t\t\tgoodKey2.PublicKey(),\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\tnil,\n\t\t\tnil,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\tnil,\n\t\t\tgoodKey1.PublicKey(),\n\t\t\tfalse,\n\t\t},\n\t}\n\n\tfor _, c := range cases {\n\t\tout := KeysEqual(c.in1, c.in2)\n\t\tif out != c.expected {\n\t\t\tt.Errorf(\"KeysEqual(%v, %v) returned %v, expected %v\", c.in1, c.in2, out, c.expected)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/ssrf/ssrf.go",
    "content": "package ssrf\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"slices\"\n\t\"strings\"\n\t\"time\"\n)\n\nvar (\n\t// ErrPrivateIP is returned when a connection to a private or internal IP is blocked.\n\tErrPrivateIP = errors.New(\"connection to private or internal IP address is not allowed\")\n\t// ErrInvalidScheme is returned when a URL scheme is not http or https.\n\tErrInvalidScheme = errors.New(\"URL must use http or https scheme\")\n\t// ErrInvalidURL is returned when a URL is invalid.\n\tErrInvalidURL = errors.New(\"invalid URL\")\n)\n\n// NewSecureClient returns an HTTP client with SSRF protection.\n// It validates resolved IPs at dial time to block connections to private\n// and internal networks. Hostnames are resolved and the validated IP is\n// used directly in the dial call to prevent DNS rebinding (TOCTOU between\n// validation and connection). Redirects are disabled to match the webhook\n// client convention and prevent redirect-based SSRF.\nfunc NewSecureClient() *http.Client {\n\treturn &http.Client{\n\t\tTimeout: 30 * time.Second,\n\t\tTransport: &http.Transport{\n\t\t\tDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {\n\t\t\t\thost, port, err := net.SplitHostPort(addr)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, err //nolint:wrapcheck\n\t\t\t\t}\n\n\t\t\t\tip := net.ParseIP(host)\n\t\t\t\tif ip == nil {\n\t\t\t\t\tips, err := net.LookupIP(host) //nolint\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn nil, fmt.Errorf(\"DNS resolution failed for host %s: %v\", host, err)\n\t\t\t\t\t}\n\t\t\t\t\tif len(ips) == 0 {\n\t\t\t\t\t\treturn nil, fmt.Errorf(\"no IP addresses found for host: %s\", host)\n\t\t\t\t\t}\n\t\t\t\t\tip = ips[0] // Use the first resolved IP address\n\t\t\t\t}\n\t\t\t\tif isPrivateOrInternal(ip) {\n\t\t\t\t\treturn nil, fmt.Errorf(\"%w\", ErrPrivateIP)\n\t\t\t\t}\n\n\t\t\t\tdialer := &net.Dialer{\n\t\t\t\t\tTimeout:   10 * time.Second,\n\t\t\t\t\tKeepAlive: 30 * time.Second,\n\t\t\t\t}\n\t\t\t\t// Dial using the validated IP to prevent DNS rebinding.\n\t\t\t\t// Without this, the dialer resolves the hostname again\n\t\t\t\t// independently, and the second resolution could return\n\t\t\t\t// a different (private) IP.\n\t\t\t\treturn dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))\n\t\t\t},\n\t\t\tMaxIdleConns:          100,\n\t\t\tIdleConnTimeout:       90 * time.Second,\n\t\t\tTLSHandshakeTimeout:   10 * time.Second,\n\t\t\tExpectContinueTimeout: 1 * time.Second,\n\t\t},\n\t\tCheckRedirect: func(*http.Request, []*http.Request) error {\n\t\t\treturn http.ErrUseLastResponse\n\t\t},\n\t}\n}\n\n// isPrivateOrInternal checks if an IP address is private, internal, or reserved.\nfunc isPrivateOrInternal(ip net.IP) bool {\n\t// Normalize IPv6-mapped IPv4 (e.g. ::ffff:127.0.0.1) to IPv4 form\n\t// so all checks apply consistently.\n\tif ip4 := ip.To4(); ip4 != nil {\n\t\tip = ip4\n\t}\n\n\tif ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||\n\t\tip.IsPrivate() || ip.IsUnspecified() || ip.IsMulticast() {\n\t\treturn true\n\t}\n\n\tif ip4 := ip.To4(); ip4 != nil {\n\t\t// 0.0.0.0/8\n\t\tif ip4[0] == 0 {\n\t\t\treturn true\n\t\t}\n\t\t// 100.64.0.0/10 (Shared Address Space / CGNAT)\n\t\tif ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 {\n\t\t\treturn true\n\t\t}\n\t\t// 192.0.0.0/24 (IETF Protocol Assignments)\n\t\tif ip4[0] == 192 && ip4[1] == 0 && ip4[2] == 0 {\n\t\t\treturn true\n\t\t}\n\t\t// 192.0.2.0/24 (TEST-NET-1)\n\t\tif ip4[0] == 192 && ip4[1] == 0 && ip4[2] == 2 {\n\t\t\treturn true\n\t\t}\n\t\t// 198.18.0.0/15 (benchmarking)\n\t\tif ip4[0] == 198 && (ip4[1] == 18 || ip4[1] == 19) {\n\t\t\treturn true\n\t\t}\n\t\t// 198.51.100.0/24 (TEST-NET-2)\n\t\tif ip4[0] == 198 && ip4[1] == 51 && ip4[2] == 100 {\n\t\t\treturn true\n\t\t}\n\t\t// 203.0.113.0/24 (TEST-NET-3)\n\t\tif ip4[0] == 203 && ip4[1] == 0 && ip4[2] == 113 {\n\t\t\treturn true\n\t\t}\n\t\t// 240.0.0.0/4 (Reserved, includes 255.255.255.255 broadcast)\n\t\tif ip4[0] >= 240 {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\n// ValidateURL validates that a URL is safe to make requests to.\n// It checks that the scheme is http/https, the hostname is not localhost,\n// and all resolved IPs are public.\nfunc ValidateURL(rawURL string) error {\n\tif rawURL == \"\" {\n\t\treturn ErrInvalidURL\n\t}\n\n\tu, err := url.Parse(rawURL)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"%w: %v\", ErrInvalidURL, err)\n\t}\n\n\tif u.Scheme != \"http\" && u.Scheme != \"https\" {\n\t\treturn ErrInvalidScheme\n\t}\n\n\thostname := u.Hostname()\n\tif hostname == \"\" {\n\t\treturn fmt.Errorf(\"%w: missing hostname\", ErrInvalidURL)\n\t}\n\n\tif isLocalhost(hostname) {\n\t\treturn ErrPrivateIP\n\t}\n\n\tif ip := net.ParseIP(hostname); ip != nil {\n\t\tif isPrivateOrInternal(ip) {\n\t\t\treturn ErrPrivateIP\n\t\t}\n\t\treturn nil\n\t}\n\n\tips, err := net.DefaultResolver.LookupIPAddr(context.Background(), hostname)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"%w: cannot resolve hostname: %v\", ErrInvalidURL, err)\n\t}\n\n\tif slices.ContainsFunc(ips, func(addr net.IPAddr) bool {\n\t\treturn isPrivateOrInternal(addr.IP)\n\t}) {\n\t\treturn ErrPrivateIP\n\t}\n\n\treturn nil\n}\n\n// ValidateIPBeforeDial validates an IP address before establishing a connection.\n// This prevents DNS rebinding attacks by checking the resolved IP at dial time.\nfunc ValidateIPBeforeDial(ip net.IP) error {\n\tif isPrivateOrInternal(ip) {\n\t\treturn ErrPrivateIP\n\t}\n\treturn nil\n}\n\n// isLocalhost checks if the hostname is localhost or similar.\nfunc isLocalhost(hostname string) bool {\n\thostname = strings.ToLower(hostname)\n\treturn hostname == \"localhost\" ||\n\t\thostname == \"localhost.localdomain\" ||\n\t\tstrings.HasSuffix(hostname, \".localhost\")\n}\n"
  },
  {
    "path": "pkg/ssrf/ssrf_test.go",
    "content": "package ssrf\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestNewSecureClientBlocksPrivateIPs(t *testing.T) {\n\tclient := NewSecureClient()\n\ttransport := client.Transport.(*http.Transport)\n\n\ttests := []struct {\n\t\tname    string\n\t\taddr    string\n\t\twantErr bool\n\t}{\n\t\t{\"block loopback\", \"127.0.0.1:80\", true},\n\t\t{\"block private 10.x\", \"10.0.0.1:80\", true},\n\t\t{\"block link-local\", \"169.254.169.254:80\", true},\n\t\t{\"block CGNAT\", \"100.64.0.1:80\", true},\n\t\t{\"allow public IP\", \"8.8.8.8:80\", false},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)\n\t\t\tdefer cancel()\n\n\t\t\tconn, err := transport.DialContext(ctx, \"tcp\", tt.addr)\n\t\t\tif conn != nil {\n\t\t\t\tconn.Close()\n\t\t\t}\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Errorf(\"expected error for %s, got none\", tt.addr)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err != nil && errors.Is(err, ErrPrivateIP) {\n\t\t\t\t\tt.Errorf(\"should not block %s with SSRF error, got: %v\", tt.addr, err)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestNewSecureClientBlocksPrivateHostnames(t *testing.T) {\n\tclient := NewSecureClient()\n\ttransport := client.Transport.(*http.Transport)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancel()\n\n\t// \"localhost\" resolves to 127.0.0.1 (loopback) -- must be blocked.\n\t// This exercises the hostname resolution path in DialContext:\n\t// net.LookupIP(\"localhost\") -> 127.0.0.1 -> isPrivateOrInternal -> blocked.\n\tconn, err := transport.DialContext(ctx, \"tcp\", \"localhost:80\")\n\tif conn != nil {\n\t\tconn.Close()\n\t}\n\tif !errors.Is(err, ErrPrivateIP) {\n\t\tt.Errorf(\"expected ErrPrivateIP for hostname resolving to loopback, got: %v\", err)\n\t}\n}\n\nfunc TestNewSecureClientNilIPNotErrPrivateIP(t *testing.T) {\n\tclient := NewSecureClient()\n\ttransport := client.Transport.(*http.Transport)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)\n\tdefer cancel()\n\n\tconn, err := transport.DialContext(ctx, \"tcp\", \"not-an-ip:80\")\n\tif conn != nil {\n\t\tconn.Close()\n\t}\n\tif err == nil {\n\t\tt.Fatal(\"expected error for non-IP address, got none\")\n\t}\n\tif errors.Is(err, ErrPrivateIP) {\n\t\tt.Errorf(\"nil-IP path should not wrap ErrPrivateIP, got: %v\", err)\n\t}\n}\n\nfunc TestNewSecureClientBlocksRedirects(t *testing.T) {\n\tredirectServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\thttp.Redirect(w, r, \"http://8.8.8.8:8080/safe\", http.StatusFound)\n\t}))\n\tdefer redirectServer.Close()\n\n\tclient := NewSecureClient()\n\treq, err := http.NewRequestWithContext(t.Context(), http.MethodGet, redirectServer.URL, nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create request: %v\", err)\n\t}\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\t// httptest uses 127.0.0.1, blocked by SSRF protection\n\t\tif !errors.Is(err, ErrPrivateIP) {\n\t\t\tt.Fatalf(\"Request failed with non-SSRF error: %v\", err)\n\t\t}\n\t\treturn\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusFound {\n\t\tt.Errorf(\"Expected redirect response (302), got %d\", resp.StatusCode)\n\t}\n}\n\nfunc TestIsPrivateOrInternal(t *testing.T) {\n\ttests := []struct {\n\t\tip   string\n\t\twant bool\n\t}{\n\t\t// Public\n\t\t{\"8.8.8.8\", false},\n\t\t{\"2001:4860:4860::8888\", false},\n\n\t\t// Loopback\n\t\t{\"127.0.0.1\", true},\n\t\t{\"::1\", true},\n\n\t\t// Private ranges\n\t\t{\"10.0.0.1\", true},\n\t\t{\"192.168.1.1\", true},\n\t\t{\"172.16.0.1\", true},\n\n\t\t// Link-local (cloud metadata)\n\t\t{\"169.254.169.254\", true},\n\n\t\t// CGNAT boundaries\n\t\t{\"100.64.0.1\", true},\n\t\t{\"100.127.255.255\", true},\n\n\t\t// IPv6-mapped IPv4 (bypass vector the old webhook code missed)\n\t\t{\"::ffff:127.0.0.1\", true},\n\t\t{\"::ffff:169.254.169.254\", true},\n\t\t{\"::ffff:8.8.8.8\", false},\n\n\t\t// Reserved\n\t\t{\"0.0.0.0\", true},\n\t\t{\"240.0.0.1\", true},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.ip, func(t *testing.T) {\n\t\t\tip := net.ParseIP(tt.ip)\n\t\t\tif ip == nil {\n\t\t\t\tt.Fatalf(\"failed to parse IP: %s\", tt.ip)\n\t\t\t}\n\t\t\tif got := isPrivateOrInternal(ip); got != tt.want {\n\t\t\t\tt.Errorf(\"isPrivateOrInternal(%s) = %v, want %v\", tt.ip, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestValidateURL(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\turl     string\n\t\twantErr bool\n\t\terrType error\n\t}{\n\t\t// Valid\n\t\t{\"valid https\", \"https://1.1.1.1/webhook\", false, nil},\n\n\t\t// Scheme validation\n\t\t{\"ftp scheme\", \"ftp://example.com/webhook\", true, ErrInvalidScheme},\n\t\t{\"no scheme\", \"example.com/webhook\", true, ErrInvalidScheme},\n\n\t\t// Localhost\n\t\t{\"localhost\", \"http://localhost/webhook\", true, ErrPrivateIP},\n\t\t{\"subdomain.localhost\", \"http://test.localhost/webhook\", true, ErrPrivateIP},\n\n\t\t// IP-based blocking (one per category -- range coverage is in TestIsPrivateOrInternal)\n\t\t{\"loopback IP\", \"http://127.0.0.1/webhook\", true, ErrPrivateIP},\n\t\t{\"metadata IP\", \"http://169.254.169.254/latest/meta-data/\", true, ErrPrivateIP},\n\n\t\t// Invalid URLs\n\t\t{\"empty\", \"\", true, ErrInvalidURL},\n\t\t{\"missing hostname\", \"http:///webhook\", true, ErrInvalidURL},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\terr := ValidateURL(tt.url)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"ValidateURL(%q) error = %v, wantErr %v\", tt.url, err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif tt.wantErr && tt.errType != nil {\n\t\t\t\tif !errors.Is(err, tt.errType) {\n\t\t\t\t\tt.Errorf(\"ValidateURL(%q) error = %v, want error type %v\", tt.url, err, tt.errType)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestIsLocalhost(t *testing.T) {\n\ttests := []struct {\n\t\thostname string\n\t\twant     bool\n\t}{\n\t\t{\"localhost\", true},\n\t\t{\"LOCALHOST\", true},\n\t\t{\"test.localhost\", true},\n\t\t{\"example.com\", false},\n\t\t{\"localhost.com\", false},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.hostname, func(t *testing.T) {\n\t\t\tif got := isLocalhost(tt.hostname); got != tt.want {\n\t\t\t\tt.Errorf(\"isLocalhost(%s) = %v, want %v\", tt.hostname, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/stats/stats.go",
    "content": "package stats\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/prometheus/client_golang/prometheus/promhttp\"\n)\n\n// StatsServer is a server for collecting and reporting statistics.\ntype StatsServer struct { //nolint:revive\n\tctx    context.Context\n\tcfg    *config.Config\n\tserver *http.Server\n}\n\n// NewStatsServer returns a new StatsServer.\nfunc NewStatsServer(ctx context.Context) (*StatsServer, error) {\n\tcfg := config.FromContext(ctx)\n\tmux := http.NewServeMux()\n\tmux.Handle(\"/metrics\", promhttp.Handler())\n\treturn &StatsServer{\n\t\tctx: ctx,\n\t\tcfg: cfg,\n\t\tserver: &http.Server{\n\t\t\tAddr:              cfg.Stats.ListenAddr,\n\t\t\tHandler:           mux,\n\t\t\tReadHeaderTimeout: time.Second * 10,\n\t\t\tReadTimeout:       time.Second * 10,\n\t\t\tWriteTimeout:      time.Second * 10,\n\t\t\tMaxHeaderBytes:    http.DefaultMaxHeaderBytes,\n\t\t},\n\t}, nil\n}\n\n// ListenAndServe starts the StatsServer.\nfunc (s *StatsServer) ListenAndServe() error {\n\treturn s.server.ListenAndServe()\n}\n\n// Shutdown gracefully shuts down the StatsServer.\nfunc (s *StatsServer) Shutdown(ctx context.Context) error {\n\treturn s.server.Shutdown(ctx)\n}\n\n// Close closes the StatsServer.\nfunc (s *StatsServer) Close() error {\n\treturn s.server.Close()\n}\n"
  },
  {
    "path": "pkg/storage/local.go",
    "content": "package storage\n\nimport (\n\t\"errors\"\n\t\"io\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n)\n\n// LocalStorage is a storage implementation that stores objects on the local\n// filesystem.\ntype LocalStorage struct {\n\troot string\n}\n\nvar _ Storage = (*LocalStorage)(nil)\n\n// NewLocalStorage creates a new LocalStorage.\nfunc NewLocalStorage(root string) *LocalStorage {\n\treturn &LocalStorage{root: root}\n}\n\n// Delete implements Storage.\nfunc (l *LocalStorage) Delete(name string) error {\n\tname = l.fixPath(name)\n\treturn os.Remove(name)\n}\n\n// Open implements Storage.\nfunc (l *LocalStorage) Open(name string) (Object, error) {\n\tname = l.fixPath(name)\n\treturn os.Open(name)\n}\n\n// Stat implements Storage.\nfunc (l *LocalStorage) Stat(name string) (fs.FileInfo, error) {\n\tname = l.fixPath(name)\n\treturn os.Stat(name)\n}\n\n// Put implements Storage.\nfunc (l *LocalStorage) Put(name string, r io.Reader) (int64, error) {\n\tname = l.fixPath(name)\n\tif err := os.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil {\n\t\treturn 0, err\n\t}\n\n\tf, err := os.Create(name)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer f.Close() //nolint: errcheck\n\treturn io.Copy(f, r)\n}\n\n// Exists implements Storage.\nfunc (l *LocalStorage) Exists(name string) (bool, error) {\n\tname = l.fixPath(name)\n\t_, err := os.Stat(name)\n\tif err == nil {\n\t\treturn true, nil\n\t}\n\tif errors.Is(err, fs.ErrNotExist) {\n\t\treturn false, nil\n\t}\n\treturn false, err\n}\n\n// Rename implements Storage.\nfunc (l *LocalStorage) Rename(oldName, newName string) error {\n\toldName = l.fixPath(oldName)\n\tnewName = l.fixPath(newName)\n\tif err := os.MkdirAll(filepath.Dir(newName), os.ModePerm); err != nil {\n\t\treturn err\n\t}\n\n\treturn os.Rename(oldName, newName)\n}\n\n// Replace all slashes with the OS-specific separator\nfunc (l LocalStorage) fixPath(path string) string {\n\tpath = strings.ReplaceAll(path, \"/\", string(os.PathSeparator))\n\tif !filepath.IsAbs(path) {\n\t\treturn filepath.Join(l.root, path)\n\t}\n\n\treturn path\n}\n"
  },
  {
    "path": "pkg/storage/storage.go",
    "content": "package storage\n\nimport (\n\t\"io\"\n\t\"io/fs\"\n)\n\n// Object is an interface for objects that can be stored.\ntype Object interface {\n\tio.Seeker\n\tfs.File\n\tName() string\n}\n\n// Storage is an interface for storing and retrieving objects.\ntype Storage interface {\n\tOpen(name string) (Object, error)\n\tStat(name string) (fs.FileInfo, error)\n\tPut(name string, r io.Reader) (int64, error)\n\tDelete(name string) error\n\tExists(name string) (bool, error)\n\tRename(oldName, newName string) error\n}\n"
  },
  {
    "path": "pkg/store/access_token.go",
    "content": "package store\n\nimport (\n\t\"context\"\n\t\"time\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db/models\"\n)\n\n// AccessTokenStore is an interface for managing access tokens.\ntype AccessTokenStore interface {\n\tGetAccessToken(ctx context.Context, h db.Handler, id int64) (models.AccessToken, error)\n\tGetAccessTokenByToken(ctx context.Context, h db.Handler, token string) (models.AccessToken, error)\n\tGetAccessTokensByUserID(ctx context.Context, h db.Handler, userID int64) ([]models.AccessToken, error)\n\tCreateAccessToken(ctx context.Context, h db.Handler, name string, userID int64, token string, expiresAt time.Time) (models.AccessToken, error)\n\tDeleteAccessToken(ctx context.Context, h db.Handler, id int64) error\n\tDeleteAccessTokenForUser(ctx context.Context, h db.Handler, userID int64, id int64) error\n}\n"
  },
  {
    "path": "pkg/store/collab.go",
    "content": "package store\n\nimport (\n\t\"context\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/access\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db/models\"\n)\n\n// CollaboratorStore is an interface for managing collaborators.\ntype CollaboratorStore interface {\n\tGetCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) (models.Collab, error)\n\tAddCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string, level access.AccessLevel) error\n\tRemoveCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) error\n\tListCollabsByRepo(ctx context.Context, h db.Handler, repo string) ([]models.Collab, error)\n\tListCollabsByRepoAsUsers(ctx context.Context, h db.Handler, repo string) ([]models.User, error)\n}\n"
  },
  {
    "path": "pkg/store/context.go",
    "content": "package store\n\nimport \"context\"\n\n// ContextKey is the store context key.\nvar ContextKey = &struct{ string }{\"store\"}\n\n// FromContext returns the store from the given context.\nfunc FromContext(ctx context.Context) Store {\n\tif s, ok := ctx.Value(ContextKey).(Store); ok {\n\t\treturn s\n\t}\n\n\treturn nil\n}\n\n// WithContext returns a new context with the given store.\nfunc WithContext(ctx context.Context, s Store) context.Context {\n\treturn context.WithValue(ctx, ContextKey, s)\n}\n"
  },
  {
    "path": "pkg/store/database/access_token.go",
    "content": "package database\n\nimport (\n\t\"context\"\n\t\"time\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db/models\"\n\t\"github.com/charmbracelet/soft-serve/pkg/store\"\n)\n\ntype accessTokenStore struct{}\n\nvar _ store.AccessTokenStore = (*accessTokenStore)(nil)\n\n// CreateAccessToken implements store.AccessTokenStore.\nfunc (s *accessTokenStore) CreateAccessToken(ctx context.Context, h db.Handler, name string, userID int64, token string, expiresAt time.Time) (models.AccessToken, error) {\n\tqueryWithoutExpires := `INSERT INTO access_tokens (name, user_id, token, created_at, updated_at)\n\tVALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) RETURNING id`\n\tqueryWithExpires := `INSERT INTO access_tokens (name, user_id, token, expires_at, created_at, updated_at)\n\tVALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) RETURNING id`\n\n\tquery := queryWithoutExpires\n\tvalues := []interface{}{name, userID, token}\n\tif !expiresAt.IsZero() {\n\t\tquery = queryWithExpires\n\t\tvalues = append(values, expiresAt.UTC())\n\t}\n\n\tvar id int64\n\tif err := h.GetContext(ctx, &id, h.Rebind(query), values...); err != nil {\n\t\treturn models.AccessToken{}, err\n\t}\n\n\treturn s.GetAccessToken(ctx, h, id)\n}\n\n// DeleteAccessToken implements store.AccessTokenStore.\nfunc (*accessTokenStore) DeleteAccessToken(ctx context.Context, h db.Handler, id int64) error {\n\tquery := h.Rebind(`DELETE FROM access_tokens WHERE id = ?`)\n\t_, err := h.ExecContext(ctx, query, id)\n\treturn err\n}\n\n// DeleteAccessTokenForUser implements store.AccessTokenStore.\nfunc (*accessTokenStore) DeleteAccessTokenForUser(ctx context.Context, h db.Handler, userID int64, id int64) error {\n\tquery := h.Rebind(`DELETE FROM access_tokens WHERE user_id = ? AND id = ?`)\n\t_, err := h.ExecContext(ctx, query, userID, id)\n\treturn err\n}\n\n// GetAccessToken implements store.AccessTokenStore.\nfunc (*accessTokenStore) GetAccessToken(ctx context.Context, h db.Handler, id int64) (models.AccessToken, error) {\n\tquery := h.Rebind(`SELECT * FROM access_tokens WHERE id = ?`)\n\tvar m models.AccessToken\n\terr := h.GetContext(ctx, &m, query, id)\n\treturn m, err\n}\n\n// GetAccessTokensByUserID implements store.AccessTokenStore.\nfunc (*accessTokenStore) GetAccessTokensByUserID(ctx context.Context, h db.Handler, userID int64) ([]models.AccessToken, error) {\n\tquery := h.Rebind(`SELECT * FROM access_tokens WHERE user_id = ?`)\n\tvar m []models.AccessToken\n\terr := h.SelectContext(ctx, &m, query, userID)\n\treturn m, err\n}\n\n// GetAccessTokenByToken implements store.AccessTokenStore.\nfunc (*accessTokenStore) GetAccessTokenByToken(ctx context.Context, h db.Handler, token string) (models.AccessToken, error) {\n\tquery := h.Rebind(`SELECT * FROM access_tokens WHERE token = ?`)\n\tvar m models.AccessToken\n\terr := h.GetContext(ctx, &m, query, token)\n\treturn m, err\n}\n"
  },
  {
    "path": "pkg/store/database/collab.go",
    "content": "package database\n\nimport (\n\t\"context\"\n\t\"strings\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/access\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db/models\"\n\t\"github.com/charmbracelet/soft-serve/pkg/store\"\n\t\"github.com/charmbracelet/soft-serve/pkg/utils\"\n)\n\ntype collabStore struct{}\n\nvar _ store.CollaboratorStore = (*collabStore)(nil)\n\n// AddCollabByUsernameAndRepo implements store.CollaboratorStore.\nfunc (*collabStore) AddCollabByUsernameAndRepo(ctx context.Context, tx db.Handler, username string, repo string, level access.AccessLevel) error {\n\tusername = strings.ToLower(username)\n\tif err := utils.ValidateUsername(username); err != nil {\n\t\treturn err\n\t}\n\n\trepo = utils.SanitizeRepo(repo)\n\n\tquery := tx.Rebind(`INSERT INTO collabs (access_level, user_id, repo_id, updated_at)\n\t\t\tVALUES (\n\t\t\t\t?,\n\t\t\t\t(\n\t\t\t\t\tSELECT id FROM users WHERE username = ?\n\t\t\t\t),\n\t\t\t\t(\n\t\t\t\t\tSELECT id FROM repos WHERE name = ?\n\t\t\t\t),\n\t\t\t\tCURRENT_TIMESTAMP\n\t\t\t);`)\n\t_, err := tx.ExecContext(ctx, query, level, username, repo)\n\treturn err\n}\n\n// GetCollabByUsernameAndRepo implements store.CollaboratorStore.\nfunc (*collabStore) GetCollabByUsernameAndRepo(ctx context.Context, tx db.Handler, username string, repo string) (models.Collab, error) {\n\tvar m models.Collab\n\n\tusername = strings.ToLower(username)\n\tif err := utils.ValidateUsername(username); err != nil {\n\t\treturn models.Collab{}, err\n\t}\n\n\trepo = utils.SanitizeRepo(repo)\n\n\terr := tx.GetContext(ctx, &m, tx.Rebind(`\n\t\tSELECT\n\t\t\tcollabs.*\n\t\tFROM\n\t\t\tcollabs\n\t\tINNER JOIN users ON users.id = collabs.user_id\n\t\tINNER JOIN repos ON repos.id = collabs.repo_id\n\t\tWHERE\n\t\t\tusers.username = ? AND repos.name = ?\n\t`), username, repo)\n\n\treturn m, err\n}\n\n// ListCollabsByRepo implements store.CollaboratorStore.\nfunc (*collabStore) ListCollabsByRepo(ctx context.Context, tx db.Handler, repo string) ([]models.Collab, error) {\n\tvar m []models.Collab\n\n\trepo = utils.SanitizeRepo(repo)\n\tquery := tx.Rebind(`\n\t\tSELECT\n\t\t\tcollabs.*\n\t\tFROM\n\t\t\tcollabs\n\t\tINNER JOIN repos ON repos.id = collabs.repo_id\n\t\tWHERE\n\t\t\trepos.name = ?\n\t`)\n\n\terr := tx.SelectContext(ctx, &m, query, repo)\n\treturn m, err\n}\n\n// ListCollabsByRepoAsUsers implements store.CollaboratorStore.\nfunc (*collabStore) ListCollabsByRepoAsUsers(ctx context.Context, tx db.Handler, repo string) ([]models.User, error) {\n\tvar m []models.User\n\n\trepo = utils.SanitizeRepo(repo)\n\tquery := tx.Rebind(`\n\t\tSELECT\n\t\t\tusers.*\n\t\tFROM\n\t\t\tusers\n\t\tINNER JOIN collabs ON collabs.user_id = users.id\n\t\tINNER JOIN repos ON repos.id = collabs.repo_id\n\t\tWHERE\n\t\t\trepos.name = ?\n\t`)\n\n\terr := tx.SelectContext(ctx, &m, query, repo)\n\treturn m, err\n}\n\n// RemoveCollabByUsernameAndRepo implements store.CollaboratorStore.\nfunc (*collabStore) RemoveCollabByUsernameAndRepo(ctx context.Context, tx db.Handler, username string, repo string) error {\n\tusername = strings.ToLower(username)\n\tif err := utils.ValidateUsername(username); err != nil {\n\t\treturn err\n\t}\n\n\trepo = utils.SanitizeRepo(repo)\n\tquery := tx.Rebind(`\n\t\tDELETE FROM\n\t\t\tcollabs\n\t\tWHERE\n\t\t\tuser_id = (\n\t\t\t\tSELECT id FROM users WHERE username = ?\n\t\t\t) AND repo_id = (\n\t\t\t\tSELECT id FROM repos WHERE name = ?\n\t\t\t)\n\t`)\n\t_, err := tx.ExecContext(ctx, query, username, repo)\n\treturn err\n}\n"
  },
  {
    "path": "pkg/store/database/database.go",
    "content": "package database\n\nimport (\n\t\"context\"\n\n\t\"charm.land/log/v2\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/store\"\n)\n\ntype datastore struct {\n\tctx    context.Context\n\tcfg    *config.Config\n\tdb     *db.DB\n\tlogger *log.Logger\n\n\t*settingsStore\n\t*repoStore\n\t*userStore\n\t*collabStore\n\t*lfsStore\n\t*accessTokenStore\n\t*webhookStore\n}\n\n// New returns a new store.Store database.\nfunc New(ctx context.Context, db *db.DB) store.Store {\n\tcfg := config.FromContext(ctx)\n\tlogger := log.FromContext(ctx).WithPrefix(\"store\")\n\n\ts := &datastore{\n\t\tctx:    ctx,\n\t\tcfg:    cfg,\n\t\tdb:     db,\n\t\tlogger: logger,\n\n\t\tsettingsStore:    &settingsStore{},\n\t\trepoStore:        &repoStore{},\n\t\tuserStore:        &userStore{},\n\t\tcollabStore:      &collabStore{},\n\t\tlfsStore:         &lfsStore{},\n\t\taccessTokenStore: &accessTokenStore{},\n\t}\n\n\treturn s\n}\n"
  },
  {
    "path": "pkg/store/database/lfs.go",
    "content": "package database\n\nimport (\n\t\"context\"\n\t\"strings\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db/models\"\n\t\"github.com/charmbracelet/soft-serve/pkg/store\"\n)\n\ntype lfsStore struct{}\n\nvar _ store.LFSStore = (*lfsStore)(nil)\n\nfunc sanitizePath(path string) string {\n\tpath = strings.TrimSpace(path)\n\tpath = strings.TrimPrefix(path, \"/\")\n\treturn path\n}\n\n// CreateLFSLockForUser implements store.LFSStore.\nfunc (*lfsStore) CreateLFSLockForUser(ctx context.Context, tx db.Handler, repoID int64, userID int64, path string, refname string) error {\n\tpath = sanitizePath(path)\n\tquery := tx.Rebind(`INSERT INTO lfs_locks (repo_id, user_id, path, refname, updated_at)\n\t\tVALUES (\n\t\t\t?,\n\t\t\t?,\n\t\t\t?,\n\t\t\t?,\n\t\t\tCURRENT_TIMESTAMP\n\t\t);\n\t`)\n\t_, err := tx.ExecContext(ctx, query, repoID, userID, path, refname)\n\treturn db.WrapError(err)\n}\n\n// GetLFSLocks implements store.LFSStore.\nfunc (*lfsStore) GetLFSLocks(ctx context.Context, tx db.Handler, repoID int64, page int, limit int) ([]models.LFSLock, error) {\n\tif page <= 0 {\n\t\tpage = 1\n\t}\n\n\tvar locks []models.LFSLock\n\tquery := tx.Rebind(`\n\t\tSELECT *\n\t\tFROM lfs_locks\n\t\tWHERE repo_id = ?\n\t\tORDER BY updated_at DESC\n\t\tLIMIT ? OFFSET ?;\n\t`)\n\terr := tx.SelectContext(ctx, &locks, query, repoID, limit, (page-1)*limit)\n\treturn locks, db.WrapError(err)\n}\n\nfunc (s *lfsStore) GetLFSLocksWithCount(ctx context.Context, tx db.Handler, repoID int64, page int, limit int) ([]models.LFSLock, int64, error) {\n\tlocks, err := s.GetLFSLocks(ctx, tx, repoID, page, limit)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\tvar count int64\n\tquery := tx.Rebind(`\n\t\tSELECT COUNT(*)\n\t\tFROM lfs_locks\n\t\tWHERE repo_id = ?;\n\t`)\n\terr = tx.GetContext(ctx, &count, query, repoID)\n\tif err != nil {\n\t\treturn nil, 0, db.WrapError(err)\n\t}\n\n\treturn locks, count, nil\n}\n\n// GetLFSLocksForUser implements store.LFSStore.\nfunc (*lfsStore) GetLFSLocksForUser(ctx context.Context, tx db.Handler, repoID int64, userID int64) ([]models.LFSLock, error) {\n\tvar locks []models.LFSLock\n\tquery := tx.Rebind(`\n\t\tSELECT *\n\t\tFROM lfs_locks\n\t\tWHERE repo_id = ? AND user_id = ?;\n\t`)\n\terr := tx.SelectContext(ctx, &locks, query, repoID, userID)\n\treturn locks, db.WrapError(err)\n}\n\n// GetLFSLocksForPath implements store.LFSStore.\nfunc (*lfsStore) GetLFSLockForPath(ctx context.Context, tx db.Handler, repoID int64, path string) (models.LFSLock, error) {\n\tpath = sanitizePath(path)\n\tvar lock models.LFSLock\n\tquery := tx.Rebind(`\n\t\tSELECT *\n\t\tFROM lfs_locks\n\t\tWHERE repo_id = ? AND path = ?;\n\t`)\n\terr := tx.GetContext(ctx, &lock, query, repoID, path)\n\treturn lock, db.WrapError(err)\n}\n\n// GetLFSLockForUserPath implements store.LFSStore.\nfunc (*lfsStore) GetLFSLockForUserPath(ctx context.Context, tx db.Handler, repoID int64, userID int64, path string) (models.LFSLock, error) {\n\tpath = sanitizePath(path)\n\tvar lock models.LFSLock\n\tquery := tx.Rebind(`\n\t\tSELECT *\n\t\tFROM lfs_locks\n\t\tWHERE repo_id = ? AND user_id = ? AND path = ?;\n\t`)\n\terr := tx.GetContext(ctx, &lock, query, repoID, userID, path)\n\treturn lock, db.WrapError(err)\n}\n\n// GetLFSLockByID implements store.LFSStore.\nfunc (*lfsStore) GetLFSLockByID(ctx context.Context, tx db.Handler, id int64) (models.LFSLock, error) {\n\tvar lock models.LFSLock\n\tquery := tx.Rebind(`\n\t\tSELECT *\n\t\tFROM lfs_locks\n\t\tWHERE lfs_locks.id = ?;\n\t`)\n\terr := tx.GetContext(ctx, &lock, query, id)\n\treturn lock, db.WrapError(err)\n}\n\n// GetLFSLockForUserByID implements store.LFSStore.\nfunc (*lfsStore) GetLFSLockForUserByID(ctx context.Context, tx db.Handler, repoID int64, userID int64, id int64) (models.LFSLock, error) {\n\tvar lock models.LFSLock\n\tquery := tx.Rebind(`\n\t\tSELECT *\n\t\tFROM lfs_locks\n\t\tWHERE id = ? AND user_id = ? AND repo_id = ?;\n\t`)\n\terr := tx.GetContext(ctx, &lock, query, id, userID, repoID)\n\treturn lock, db.WrapError(err)\n}\n\n// DeleteLFSLockForUserByID implements store.LFSStore.\nfunc (*lfsStore) DeleteLFSLockForUserByID(ctx context.Context, tx db.Handler, repoID int64, userID int64, id int64) error {\n\tquery := tx.Rebind(`\n\t\tDELETE FROM lfs_locks\n\t\tWHERE repo_id = ? AND user_id = ? AND id = ?;\n\t`)\n\t_, err := tx.ExecContext(ctx, query, repoID, userID, id)\n\treturn db.WrapError(err)\n}\n\n// DeleteLFSLock implements store.LFSStore.\nfunc (*lfsStore) DeleteLFSLock(ctx context.Context, tx db.Handler, repoID int64, id int64) error {\n\tquery := tx.Rebind(`\n\t\tDELETE FROM lfs_locks\n\t\tWHERE repo_id = ? AND id = ?;\n\t`)\n\t_, err := tx.ExecContext(ctx, query, repoID, id)\n\treturn db.WrapError(err)\n}\n\n// CreateLFSObject implements store.LFSStore.\nfunc (*lfsStore) CreateLFSObject(ctx context.Context, tx db.Handler, repoID int64, oid string, size int64) error {\n\tquery := tx.Rebind(`INSERT INTO lfs_objects (repo_id, oid, size, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP);`)\n\t_, err := tx.ExecContext(ctx, query, repoID, oid, size)\n\treturn db.WrapError(err)\n}\n\n// DeleteLFSObjectByOid implements store.LFSStore.\nfunc (*lfsStore) DeleteLFSObjectByOid(ctx context.Context, tx db.Handler, repoID int64, oid string) error {\n\tquery := tx.Rebind(`DELETE FROM lfs_objects WHERE repo_id = ? AND oid = ?;`)\n\t_, err := tx.ExecContext(ctx, query, repoID, oid)\n\treturn db.WrapError(err)\n}\n\n// GetLFSObjectByOid implements store.LFSStore.\nfunc (*lfsStore) GetLFSObjectByOid(ctx context.Context, tx db.Handler, repoID int64, oid string) (models.LFSObject, error) {\n\tvar obj models.LFSObject\n\tquery := tx.Rebind(`SELECT * FROM lfs_objects WHERE repo_id = ? AND oid = ?;`)\n\terr := tx.GetContext(ctx, &obj, query, repoID, oid)\n\treturn obj, db.WrapError(err)\n}\n\n// GetLFSObjects implements store.LFSStore.\nfunc (*lfsStore) GetLFSObjects(ctx context.Context, tx db.Handler, repoID int64) ([]models.LFSObject, error) {\n\tvar objs []models.LFSObject\n\tquery := tx.Rebind(`SELECT * FROM lfs_objects WHERE repo_id = ?;`)\n\terr := tx.SelectContext(ctx, &objs, query, repoID)\n\treturn objs, db.WrapError(err)\n}\n\n// GetLFSObjectsByName implements store.LFSStore.\nfunc (*lfsStore) GetLFSObjectsByName(ctx context.Context, tx db.Handler, name string) ([]models.LFSObject, error) {\n\tvar objs []models.LFSObject\n\tquery := tx.Rebind(`\n\t\tSELECT lfs_objects.*\n\t\tFROM lfs_objects\n\t\tINNER JOIN repos ON lfs_objects.repo_id = repos.id\n\t\tWHERE repos.name = ?;\n\t`)\n\terr := tx.SelectContext(ctx, &objs, query, name)\n\treturn objs, db.WrapError(err)\n}\n"
  },
  {
    "path": "pkg/store/database/repo.go",
    "content": "package database\n\nimport (\n\t\"context\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db/models\"\n\t\"github.com/charmbracelet/soft-serve/pkg/store\"\n\t\"github.com/charmbracelet/soft-serve/pkg/utils\"\n)\n\ntype repoStore struct{}\n\nvar _ store.RepositoryStore = (*repoStore)(nil)\n\n// CreateRepo implements store.RepositoryStore.\nfunc (*repoStore) CreateRepo(ctx context.Context, tx db.Handler, name string, userID int64, projectName string, description string, isPrivate bool, isHidden bool, isMirror bool) error {\n\tname = utils.SanitizeRepo(name)\n\tvalues := []interface{}{\n\t\tname, projectName, description, isPrivate, isMirror, isHidden,\n\t}\n\tquery := `INSERT INTO repos (name, project_name, description, private, mirror, hidden, updated_at)\n\t\t\tVALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`\n\tif userID > 0 {\n\t\tquery = `INSERT INTO repos (name, project_name, description, private, mirror, hidden, updated_at, user_id)\n\t\t\tVALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?);`\n\t\tvalues = append(values, userID)\n\t}\n\n\tquery = tx.Rebind(query)\n\t_, err := tx.ExecContext(ctx, query, values...)\n\treturn db.WrapError(err)\n}\n\n// DeleteRepoByName implements store.RepositoryStore.\nfunc (*repoStore) DeleteRepoByName(ctx context.Context, tx db.Handler, name string) error {\n\tname = utils.SanitizeRepo(name)\n\tquery := tx.Rebind(\"DELETE FROM repos WHERE name = ?;\")\n\t_, err := tx.ExecContext(ctx, query, name)\n\treturn db.WrapError(err)\n}\n\n// GetAllRepos implements store.RepositoryStore.\nfunc (*repoStore) GetAllRepos(ctx context.Context, tx db.Handler) ([]models.Repo, error) {\n\tvar repos []models.Repo\n\tquery := tx.Rebind(\"SELECT * FROM repos;\")\n\terr := tx.SelectContext(ctx, &repos, query)\n\treturn repos, db.WrapError(err)\n}\n\n// GetUserRepos implements store.RepositoryStore.\nfunc (*repoStore) GetUserRepos(ctx context.Context, tx db.Handler, userID int64) ([]models.Repo, error) {\n\tvar repos []models.Repo\n\tquery := tx.Rebind(\"SELECT * FROM repos WHERE user_id = ?;\")\n\terr := tx.SelectContext(ctx, &repos, query, userID)\n\treturn repos, db.WrapError(err)\n}\n\n// GetRepoByName implements store.RepositoryStore.\nfunc (*repoStore) GetRepoByName(ctx context.Context, tx db.Handler, name string) (models.Repo, error) {\n\tvar repo models.Repo\n\tname = utils.SanitizeRepo(name)\n\tquery := tx.Rebind(\"SELECT * FROM repos WHERE name = ?;\")\n\terr := tx.GetContext(ctx, &repo, query, name)\n\treturn repo, db.WrapError(err)\n}\n\n// GetRepoDescriptionByName implements store.RepositoryStore.\nfunc (*repoStore) GetRepoDescriptionByName(ctx context.Context, tx db.Handler, name string) (string, error) {\n\tvar description string\n\tname = utils.SanitizeRepo(name)\n\tquery := tx.Rebind(\"SELECT description FROM repos WHERE name = ?;\")\n\terr := tx.GetContext(ctx, &description, query, name)\n\treturn description, db.WrapError(err)\n}\n\n// GetRepoIsHiddenByName implements store.RepositoryStore.\nfunc (*repoStore) GetRepoIsHiddenByName(ctx context.Context, tx db.Handler, name string) (bool, error) {\n\tvar isHidden bool\n\tname = utils.SanitizeRepo(name)\n\tquery := tx.Rebind(\"SELECT hidden FROM repos WHERE name = ?;\")\n\terr := tx.GetContext(ctx, &isHidden, query, name)\n\treturn isHidden, db.WrapError(err)\n}\n\n// GetRepoIsMirrorByName implements store.RepositoryStore.\nfunc (*repoStore) GetRepoIsMirrorByName(ctx context.Context, tx db.Handler, name string) (bool, error) {\n\tvar isMirror bool\n\tname = utils.SanitizeRepo(name)\n\tquery := tx.Rebind(\"SELECT mirror FROM repos WHERE name = ?;\")\n\terr := tx.GetContext(ctx, &isMirror, query, name)\n\treturn isMirror, db.WrapError(err)\n}\n\n// GetRepoIsPrivateByName implements store.RepositoryStore.\nfunc (*repoStore) GetRepoIsPrivateByName(ctx context.Context, tx db.Handler, name string) (bool, error) {\n\tvar isPrivate bool\n\tname = utils.SanitizeRepo(name)\n\tquery := tx.Rebind(\"SELECT private FROM repos WHERE name = ?;\")\n\terr := tx.GetContext(ctx, &isPrivate, query, name)\n\treturn isPrivate, db.WrapError(err)\n}\n\n// GetRepoProjectNameByName implements store.RepositoryStore.\nfunc (*repoStore) GetRepoProjectNameByName(ctx context.Context, tx db.Handler, name string) (string, error) {\n\tvar pname string\n\tname = utils.SanitizeRepo(name)\n\tquery := tx.Rebind(\"SELECT project_name FROM repos WHERE name = ?;\")\n\terr := tx.GetContext(ctx, &pname, query, name)\n\treturn pname, db.WrapError(err)\n}\n\n// SetRepoDescriptionByName implements store.RepositoryStore.\nfunc (*repoStore) SetRepoDescriptionByName(ctx context.Context, tx db.Handler, name string, description string) error {\n\tname = utils.SanitizeRepo(name)\n\tquery := tx.Rebind(\"UPDATE repos SET description = ? WHERE name = ?;\")\n\t_, err := tx.ExecContext(ctx, query, description, name)\n\treturn db.WrapError(err)\n}\n\n// SetRepoIsHiddenByName implements store.RepositoryStore.\nfunc (*repoStore) SetRepoIsHiddenByName(ctx context.Context, tx db.Handler, name string, isHidden bool) error {\n\tname = utils.SanitizeRepo(name)\n\tquery := tx.Rebind(\"UPDATE repos SET hidden = ? WHERE name = ?;\")\n\t_, err := tx.ExecContext(ctx, query, isHidden, name)\n\treturn db.WrapError(err)\n}\n\n// SetRepoIsPrivateByName implements store.RepositoryStore.\nfunc (*repoStore) SetRepoIsPrivateByName(ctx context.Context, tx db.Handler, name string, isPrivate bool) error {\n\tname = utils.SanitizeRepo(name)\n\tquery := tx.Rebind(\"UPDATE repos SET private = ? WHERE name = ?;\")\n\t_, err := tx.ExecContext(ctx, query, isPrivate, name)\n\treturn db.WrapError(err)\n}\n\n// SetRepoNameByName implements store.RepositoryStore.\nfunc (*repoStore) SetRepoNameByName(ctx context.Context, tx db.Handler, name string, newName string) error {\n\tname = utils.SanitizeRepo(name)\n\tnewName = utils.SanitizeRepo(newName)\n\tquery := tx.Rebind(\"UPDATE repos SET name = ? WHERE name = ?;\")\n\t_, err := tx.ExecContext(ctx, query, newName, name)\n\treturn db.WrapError(err)\n}\n\n// SetRepoProjectNameByName implements store.RepositoryStore.\nfunc (*repoStore) SetRepoProjectNameByName(ctx context.Context, tx db.Handler, name string, projectName string) error {\n\tname = utils.SanitizeRepo(name)\n\tquery := tx.Rebind(\"UPDATE repos SET project_name = ? WHERE name = ?;\")\n\t_, err := tx.ExecContext(ctx, query, projectName, name)\n\treturn db.WrapError(err)\n}\n"
  },
  {
    "path": "pkg/store/database/settings.go",
    "content": "package database\n\nimport (\n\t\"context\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/access\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/store\"\n)\n\ntype settingsStore struct{}\n\nvar _ store.SettingStore = (*settingsStore)(nil)\n\n// GetAllowKeylessAccess implements store.SettingStore.\nfunc (*settingsStore) GetAllowKeylessAccess(ctx context.Context, tx db.Handler) (bool, error) {\n\tvar allow bool\n\tquery := tx.Rebind(`SELECT value FROM settings WHERE \"key\" = 'allow_keyless'`)\n\tif err := tx.GetContext(ctx, &allow, query); err != nil {\n\t\treturn false, db.WrapError(err)\n\t}\n\treturn allow, nil\n}\n\n// GetAnonAccess implements store.SettingStore.\nfunc (*settingsStore) GetAnonAccess(ctx context.Context, tx db.Handler) (access.AccessLevel, error) {\n\tvar level string\n\tquery := tx.Rebind(`SELECT value FROM settings WHERE \"key\" = 'anon_access'`)\n\tif err := tx.GetContext(ctx, &level, query); err != nil {\n\t\treturn access.NoAccess, db.WrapError(err)\n\t}\n\treturn access.ParseAccessLevel(level), nil\n}\n\n// SetAllowKeylessAccess implements store.SettingStore.\nfunc (*settingsStore) SetAllowKeylessAccess(ctx context.Context, tx db.Handler, allow bool) error {\n\tquery := tx.Rebind(`UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE \"key\" = 'allow_keyless'`)\n\t_, err := tx.ExecContext(ctx, query, allow)\n\treturn db.WrapError(err)\n}\n\n// SetAnonAccess implements store.SettingStore.\nfunc (*settingsStore) SetAnonAccess(ctx context.Context, tx db.Handler, level access.AccessLevel) error {\n\tquery := tx.Rebind(`UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE \"key\" = 'anon_access'`)\n\t_, err := tx.ExecContext(ctx, query, level.String())\n\treturn db.WrapError(err)\n}\n"
  },
  {
    "path": "pkg/store/database/user.go",
    "content": "package database\n\nimport (\n\t\"context\"\n\t\"strings\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db/models\"\n\t\"github.com/charmbracelet/soft-serve/pkg/sshutils\"\n\t\"github.com/charmbracelet/soft-serve/pkg/store\"\n\t\"github.com/charmbracelet/soft-serve/pkg/utils\"\n\t\"golang.org/x/crypto/ssh\"\n)\n\ntype userStore struct{}\n\nvar _ store.UserStore = (*userStore)(nil)\n\n// AddPublicKeyByUsername implements store.UserStore.\nfunc (*userStore) AddPublicKeyByUsername(ctx context.Context, tx db.Handler, username string, pk ssh.PublicKey) error {\n\tusername = strings.ToLower(username)\n\tif err := utils.ValidateUsername(username); err != nil {\n\t\treturn err\n\t}\n\n\tvar userID int64\n\tif err := tx.GetContext(ctx, &userID, tx.Rebind(`SELECT id FROM users WHERE username = ?`), username); err != nil {\n\t\treturn err\n\t}\n\n\tquery := tx.Rebind(`INSERT INTO public_keys (user_id, public_key, updated_at)\n\t\t\tVALUES (?, ?, CURRENT_TIMESTAMP);`)\n\tak := sshutils.MarshalAuthorizedKey(pk)\n\t_, err := tx.ExecContext(ctx, query, userID, ak)\n\n\treturn err\n}\n\n// CreateUser implements store.UserStore.\nfunc (*userStore) CreateUser(ctx context.Context, tx db.Handler, username string, isAdmin bool, pks []ssh.PublicKey) error {\n\tusername = strings.ToLower(username)\n\tif err := utils.ValidateUsername(username); err != nil {\n\t\treturn err\n\t}\n\n\tquery := tx.Rebind(`INSERT INTO users (username, admin, updated_at)\n\t\t\tVALUES (?, ?, CURRENT_TIMESTAMP) RETURNING id;`)\n\n\tvar userID int64\n\tif err := tx.GetContext(ctx, &userID, query, username, isAdmin); err != nil {\n\t\treturn err\n\t}\n\n\tfor _, pk := range pks {\n\t\tquery := tx.Rebind(`INSERT INTO public_keys (user_id, public_key, updated_at)\n\t\t\tVALUES (?, ?, CURRENT_TIMESTAMP);`)\n\t\tak := sshutils.MarshalAuthorizedKey(pk)\n\t\t_, err := tx.ExecContext(ctx, query, userID, ak)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// DeleteUserByUsername implements store.UserStore.\nfunc (*userStore) DeleteUserByUsername(ctx context.Context, tx db.Handler, username string) error {\n\tusername = strings.ToLower(username)\n\tif err := utils.ValidateUsername(username); err != nil {\n\t\treturn err\n\t}\n\n\tquery := tx.Rebind(`DELETE FROM users WHERE username = ?;`)\n\t_, err := tx.ExecContext(ctx, query, username)\n\treturn err\n}\n\n// GetUserByID implements store.UserStore.\nfunc (*userStore) GetUserByID(ctx context.Context, tx db.Handler, id int64) (models.User, error) {\n\tvar m models.User\n\tquery := tx.Rebind(`SELECT * FROM users WHERE id = ?;`)\n\terr := tx.GetContext(ctx, &m, query, id)\n\treturn m, err\n}\n\n// FindUserByPublicKey implements store.UserStore.\nfunc (*userStore) FindUserByPublicKey(ctx context.Context, tx db.Handler, pk ssh.PublicKey) (models.User, error) {\n\tvar m models.User\n\tquery := tx.Rebind(`SELECT users.*\n\t\t\tFROM users\n\t\t\tINNER JOIN public_keys ON users.id = public_keys.user_id\n\t\t\tWHERE public_keys.public_key = ?;`)\n\terr := tx.GetContext(ctx, &m, query, sshutils.MarshalAuthorizedKey(pk))\n\treturn m, err\n}\n\n// FindUserByUsername implements store.UserStore.\nfunc (*userStore) FindUserByUsername(ctx context.Context, tx db.Handler, username string) (models.User, error) {\n\tusername = strings.ToLower(username)\n\tif err := utils.ValidateUsername(username); err != nil {\n\t\treturn models.User{}, err\n\t}\n\n\tvar m models.User\n\tquery := tx.Rebind(`SELECT * FROM users WHERE username = ?;`)\n\terr := tx.GetContext(ctx, &m, query, username)\n\treturn m, err\n}\n\n// FindUserByAccessToken implements store.UserStore.\nfunc (*userStore) FindUserByAccessToken(ctx context.Context, tx db.Handler, token string) (models.User, error) {\n\tvar m models.User\n\tquery := tx.Rebind(`SELECT users.*\n\t\t\tFROM users\n\t\t\tINNER JOIN access_tokens ON users.id = access_tokens.user_id\n\t\t\tWHERE access_tokens.token = ?;`)\n\terr := tx.GetContext(ctx, &m, query, token)\n\treturn m, err\n}\n\n// GetAllUsers implements store.UserStore.\nfunc (*userStore) GetAllUsers(ctx context.Context, tx db.Handler) ([]models.User, error) {\n\tvar ms []models.User\n\tquery := tx.Rebind(`SELECT * FROM users;`)\n\terr := tx.SelectContext(ctx, &ms, query)\n\treturn ms, err\n}\n\n// ListPublicKeysByUserID implements store.UserStore..\nfunc (*userStore) ListPublicKeysByUserID(ctx context.Context, tx db.Handler, id int64) ([]ssh.PublicKey, error) {\n\tvar aks []string\n\tquery := tx.Rebind(`SELECT public_key FROM public_keys\n\t\t\tWHERE user_id = ?\n\t\t\tORDER BY public_keys.id ASC;`)\n\terr := tx.SelectContext(ctx, &aks, query, id)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tpks := make([]ssh.PublicKey, len(aks))\n\tfor i, ak := range aks {\n\t\tpk, _, err := sshutils.ParseAuthorizedKey(ak)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tpks[i] = pk\n\t}\n\n\treturn pks, nil\n}\n\n// ListPublicKeysByUsername implements store.UserStore.\nfunc (*userStore) ListPublicKeysByUsername(ctx context.Context, tx db.Handler, username string) ([]ssh.PublicKey, error) {\n\tusername = strings.ToLower(username)\n\tif err := utils.ValidateUsername(username); err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar aks []string\n\tquery := tx.Rebind(`SELECT public_key FROM public_keys\n\t\t\tINNER JOIN users ON users.id = public_keys.user_id\n\t\t\tWHERE users.username = ?\n\t\t\tORDER BY public_keys.id ASC;`)\n\terr := tx.SelectContext(ctx, &aks, query, username)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tpks := make([]ssh.PublicKey, len(aks))\n\tfor i, ak := range aks {\n\t\tpk, _, err := sshutils.ParseAuthorizedKey(ak)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tpks[i] = pk\n\t}\n\n\treturn pks, nil\n}\n\n// RemovePublicKeyByUsername implements store.UserStore.\nfunc (*userStore) RemovePublicKeyByUsername(ctx context.Context, tx db.Handler, username string, pk ssh.PublicKey) error {\n\tusername = strings.ToLower(username)\n\tif err := utils.ValidateUsername(username); err != nil {\n\t\treturn err\n\t}\n\n\tquery := tx.Rebind(`DELETE FROM public_keys\n\t\t\tWHERE user_id = (SELECT id FROM users WHERE username = ?)\n\t\t\tAND public_key = ?;`)\n\t_, err := tx.ExecContext(ctx, query, username, sshutils.MarshalAuthorizedKey(pk))\n\treturn err\n}\n\n// SetAdminByUsername implements store.UserStore.\nfunc (*userStore) SetAdminByUsername(ctx context.Context, tx db.Handler, username string, isAdmin bool) error {\n\tusername = strings.ToLower(username)\n\tif err := utils.ValidateUsername(username); err != nil {\n\t\treturn err\n\t}\n\n\tquery := tx.Rebind(`UPDATE users SET admin = ? WHERE username = ?;`)\n\t_, err := tx.ExecContext(ctx, query, isAdmin, username)\n\treturn err\n}\n\n// SetUsernameByUsername implements store.UserStore.\nfunc (*userStore) SetUsernameByUsername(ctx context.Context, tx db.Handler, username string, newUsername string) error {\n\tusername = strings.ToLower(username)\n\tif err := utils.ValidateUsername(username); err != nil {\n\t\treturn err\n\t}\n\n\tnewUsername = strings.ToLower(newUsername)\n\tif err := utils.ValidateUsername(newUsername); err != nil {\n\t\treturn err\n\t}\n\n\tquery := tx.Rebind(`UPDATE users SET username = ? WHERE username = ?;`)\n\t_, err := tx.ExecContext(ctx, query, newUsername, username)\n\treturn err\n}\n\n// SetUserPassword implements store.UserStore.\nfunc (*userStore) SetUserPassword(ctx context.Context, tx db.Handler, userID int64, password string) error {\n\tquery := tx.Rebind(`UPDATE users SET password = ? WHERE id = ?;`)\n\t_, err := tx.ExecContext(ctx, query, password, userID)\n\treturn err\n}\n\n// SetUserPasswordByUsername implements store.UserStore.\nfunc (*userStore) SetUserPasswordByUsername(ctx context.Context, tx db.Handler, username string, password string) error {\n\tusername = strings.ToLower(username)\n\tif err := utils.ValidateUsername(username); err != nil {\n\t\treturn err\n\t}\n\n\tquery := tx.Rebind(`UPDATE users SET password = ? WHERE username = ?;`)\n\t_, err := tx.ExecContext(ctx, query, password, username)\n\treturn err\n}\n"
  },
  {
    "path": "pkg/store/database/webhooks.go",
    "content": "package database\n\nimport (\n\t\"context\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db/models\"\n\t\"github.com/charmbracelet/soft-serve/pkg/store\"\n\t\"github.com/google/uuid\"\n\t\"github.com/jmoiron/sqlx\"\n)\n\ntype webhookStore struct{}\n\nvar _ store.WebhookStore = (*webhookStore)(nil)\n\n// CreateWebhook implements store.WebhookStore.\nfunc (*webhookStore) CreateWebhook(ctx context.Context, h db.Handler, repoID int64, url string, secret string, contentType int, active bool) (int64, error) {\n\tvar id int64\n\tquery := h.Rebind(`INSERT INTO webhooks (repo_id, url, secret, content_type, active, updated_at)\n\t\t\tVALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) RETURNING id;`)\n\terr := h.GetContext(ctx, &id, query, repoID, url, secret, contentType, active)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn id, nil\n}\n\n// CreateWebhookDelivery implements store.WebhookStore.\nfunc (*webhookStore) CreateWebhookDelivery(ctx context.Context, h db.Handler, id uuid.UUID, webhookID int64, event int, url string, method string, requestError error, requestHeaders string, requestBody string, responseStatus int, responseHeaders string, responseBody string) error {\n\tquery := h.Rebind(`INSERT INTO webhook_deliveries (id, webhook_id, event, request_url, request_method, request_error, request_headers, request_body, response_status, response_headers, response_body)\n\t\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`)\n\tvar reqErr string\n\tif requestError != nil {\n\t\treqErr = requestError.Error()\n\t}\n\t_, err := h.ExecContext(ctx, query, id, webhookID, event, url, method, reqErr, requestHeaders, requestBody, responseStatus, responseHeaders, responseBody)\n\treturn err\n}\n\n// CreateWebhookEvents implements store.WebhookStore.\nfunc (*webhookStore) CreateWebhookEvents(ctx context.Context, h db.Handler, webhookID int64, events []int) error {\n\tquery := h.Rebind(`INSERT INTO webhook_events (webhook_id, event)\n\t\t\tVALUES (?, ?);`)\n\tfor _, event := range events {\n\t\t_, err := h.ExecContext(ctx, query, webhookID, event)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// DeleteWebhookByID implements store.WebhookStore.\nfunc (*webhookStore) DeleteWebhookByID(ctx context.Context, h db.Handler, id int64) error {\n\tquery := h.Rebind(`DELETE FROM webhooks WHERE id = ?;`)\n\t_, err := h.ExecContext(ctx, query, id)\n\treturn err\n}\n\n// DeleteWebhookForRepoByID implements store.WebhookStore.\nfunc (*webhookStore) DeleteWebhookForRepoByID(ctx context.Context, h db.Handler, repoID int64, id int64) error {\n\tquery := h.Rebind(`DELETE FROM webhooks WHERE repo_id = ? AND id = ?;`)\n\t_, err := h.ExecContext(ctx, query, repoID, id)\n\treturn err\n}\n\n// DeleteWebhookDeliveryByID implements store.WebhookStore.\nfunc (*webhookStore) DeleteWebhookDeliveryByID(ctx context.Context, h db.Handler, webhookID int64, id uuid.UUID) error {\n\tquery := h.Rebind(`DELETE FROM webhook_deliveries WHERE webhook_id = ? AND id = ?;`)\n\t_, err := h.ExecContext(ctx, query, webhookID, id)\n\treturn err\n}\n\n// DeleteWebhookEventsByWebhookID implements store.WebhookStore.\nfunc (*webhookStore) DeleteWebhookEventsByID(ctx context.Context, h db.Handler, ids []int64) error {\n\tquery, args, err := sqlx.In(`DELETE FROM webhook_events WHERE id IN (?);`, ids)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tquery = h.Rebind(query)\n\t_, err = h.ExecContext(ctx, query, args...)\n\treturn err\n}\n\n// GetWebhookByID implements store.WebhookStore.\nfunc (*webhookStore) GetWebhookByID(ctx context.Context, h db.Handler, repoID int64, id int64) (models.Webhook, error) {\n\tquery := h.Rebind(`SELECT * FROM webhooks WHERE repo_id = ? AND id = ?;`)\n\tvar wh models.Webhook\n\terr := h.GetContext(ctx, &wh, query, repoID, id)\n\treturn wh, err\n}\n\n// GetWebhookDeliveriesByWebhookID implements store.WebhookStore.\nfunc (*webhookStore) GetWebhookDeliveriesByWebhookID(ctx context.Context, h db.Handler, webhookID int64) ([]models.WebhookDelivery, error) {\n\tquery := h.Rebind(`SELECT * FROM webhook_deliveries WHERE webhook_id = ?;`)\n\tvar whds []models.WebhookDelivery\n\terr := h.SelectContext(ctx, &whds, query, webhookID)\n\treturn whds, err\n}\n\n// GetWebhookDeliveryByID implements store.WebhookStore.\nfunc (*webhookStore) GetWebhookDeliveryByID(ctx context.Context, h db.Handler, webhookID int64, id uuid.UUID) (models.WebhookDelivery, error) {\n\tquery := h.Rebind(`SELECT * FROM webhook_deliveries WHERE webhook_id = ? AND id = ?;`)\n\tvar whd models.WebhookDelivery\n\terr := h.GetContext(ctx, &whd, query, webhookID, id)\n\treturn whd, err\n}\n\n// GetWebhookEventByID implements store.WebhookStore.\nfunc (*webhookStore) GetWebhookEventByID(ctx context.Context, h db.Handler, id int64) (models.WebhookEvent, error) {\n\tquery := h.Rebind(`SELECT * FROM webhook_events WHERE id = ?;`)\n\tvar whe models.WebhookEvent\n\terr := h.GetContext(ctx, &whe, query, id)\n\treturn whe, err\n}\n\n// GetWebhookEventsByWebhookID implements store.WebhookStore.\nfunc (*webhookStore) GetWebhookEventsByWebhookID(ctx context.Context, h db.Handler, webhookID int64) ([]models.WebhookEvent, error) {\n\tquery := h.Rebind(`SELECT * FROM webhook_events WHERE webhook_id = ?;`)\n\tvar whes []models.WebhookEvent\n\terr := h.SelectContext(ctx, &whes, query, webhookID)\n\treturn whes, err\n}\n\n// GetWebhooksByRepoID implements store.WebhookStore.\nfunc (*webhookStore) GetWebhooksByRepoID(ctx context.Context, h db.Handler, repoID int64) ([]models.Webhook, error) {\n\tquery := h.Rebind(`SELECT * FROM webhooks WHERE repo_id = ?;`)\n\tvar whs []models.Webhook\n\terr := h.SelectContext(ctx, &whs, query, repoID)\n\treturn whs, err\n}\n\n// GetWebhooksByRepoIDWhereEvent implements store.WebhookStore.\nfunc (*webhookStore) GetWebhooksByRepoIDWhereEvent(ctx context.Context, h db.Handler, repoID int64, events []int) ([]models.Webhook, error) {\n\tquery, args, err := sqlx.In(`SELECT webhooks.*\n\t\t\tFROM webhooks\n\t\t\tINNER JOIN webhook_events ON webhooks.id = webhook_events.webhook_id\n\t\t\tWHERE webhooks.repo_id = ? AND webhook_events.event IN (?);`, repoID, events)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tquery = h.Rebind(query)\n\tvar whs []models.Webhook\n\terr = h.SelectContext(ctx, &whs, query, args...)\n\treturn whs, err\n}\n\n// ListWebhookDeliveriesByWebhookID implements store.WebhookStore.\nfunc (*webhookStore) ListWebhookDeliveriesByWebhookID(ctx context.Context, h db.Handler, webhookID int64) ([]models.WebhookDelivery, error) {\n\tquery := h.Rebind(`SELECT id, response_status, event FROM webhook_deliveries WHERE webhook_id = ?;`)\n\tvar whds []models.WebhookDelivery\n\terr := h.SelectContext(ctx, &whds, query, webhookID)\n\treturn whds, err\n}\n\n// UpdateWebhookByID implements store.WebhookStore.\nfunc (*webhookStore) UpdateWebhookByID(ctx context.Context, h db.Handler, repoID int64, id int64, url string, secret string, contentType int, active bool) error {\n\tquery := h.Rebind(`UPDATE webhooks SET url = ?, secret = ?, content_type = ?, active = ?, updated_at = CURRENT_TIMESTAMP WHERE repo_id = ? AND id = ?;`)\n\t_, err := h.ExecContext(ctx, query, url, secret, contentType, active, repoID, id)\n\treturn err\n}\n"
  },
  {
    "path": "pkg/store/lfs.go",
    "content": "package store\n\nimport (\n\t\"context\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db/models\"\n)\n\n// LFSStore is the interface for the LFS store.\ntype LFSStore interface {\n\tCreateLFSObject(ctx context.Context, h db.Handler, repoID int64, oid string, size int64) error\n\tGetLFSObjectByOid(ctx context.Context, h db.Handler, repoID int64, oid string) (models.LFSObject, error)\n\tGetLFSObjects(ctx context.Context, h db.Handler, repoID int64) ([]models.LFSObject, error)\n\tGetLFSObjectsByName(ctx context.Context, h db.Handler, name string) ([]models.LFSObject, error)\n\tDeleteLFSObjectByOid(ctx context.Context, h db.Handler, repoID int64, oid string) error\n\n\tCreateLFSLockForUser(ctx context.Context, h db.Handler, repoID int64, userID int64, path string, refname string) error\n\tGetLFSLocks(ctx context.Context, h db.Handler, repoID int64, page int, limit int) ([]models.LFSLock, error)\n\tGetLFSLocksWithCount(ctx context.Context, h db.Handler, repoID int64, page int, limit int) ([]models.LFSLock, int64, error)\n\tGetLFSLocksForUser(ctx context.Context, h db.Handler, repoID int64, userID int64) ([]models.LFSLock, error)\n\tGetLFSLockForPath(ctx context.Context, h db.Handler, repoID int64, path string) (models.LFSLock, error)\n\tGetLFSLockForUserPath(ctx context.Context, h db.Handler, repoID int64, userID int64, path string) (models.LFSLock, error)\n\tGetLFSLockByID(ctx context.Context, h db.Handler, id int64) (models.LFSLock, error)\n\tGetLFSLockForUserByID(ctx context.Context, h db.Handler, repoID int64, userID int64, id int64) (models.LFSLock, error)\n\tDeleteLFSLock(ctx context.Context, h db.Handler, repoID int64, id int64) error\n\tDeleteLFSLockForUserByID(ctx context.Context, h db.Handler, repoID int64, userID int64, id int64) error\n}\n"
  },
  {
    "path": "pkg/store/repo.go",
    "content": "package store\n\nimport (\n\t\"context\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db/models\"\n)\n\n// RepositoryStore is an interface for managing repositories.\ntype RepositoryStore interface {\n\tGetRepoByName(ctx context.Context, h db.Handler, name string) (models.Repo, error)\n\tGetAllRepos(ctx context.Context, h db.Handler) ([]models.Repo, error)\n\tGetUserRepos(ctx context.Context, h db.Handler, userID int64) ([]models.Repo, error)\n\tCreateRepo(ctx context.Context, h db.Handler, name string, userID int64, projectName string, description string, isPrivate bool, isHidden bool, isMirror bool) error\n\tDeleteRepoByName(ctx context.Context, h db.Handler, name string) error\n\tSetRepoNameByName(ctx context.Context, h db.Handler, name string, newName string) error\n\n\tGetRepoProjectNameByName(ctx context.Context, h db.Handler, name string) (string, error)\n\tSetRepoProjectNameByName(ctx context.Context, h db.Handler, name string, projectName string) error\n\tGetRepoDescriptionByName(ctx context.Context, h db.Handler, name string) (string, error)\n\tSetRepoDescriptionByName(ctx context.Context, h db.Handler, name string, description string) error\n\tGetRepoIsPrivateByName(ctx context.Context, h db.Handler, name string) (bool, error)\n\tSetRepoIsPrivateByName(ctx context.Context, h db.Handler, name string, isPrivate bool) error\n\tGetRepoIsHiddenByName(ctx context.Context, h db.Handler, name string) (bool, error)\n\tSetRepoIsHiddenByName(ctx context.Context, h db.Handler, name string, isHidden bool) error\n\tGetRepoIsMirrorByName(ctx context.Context, h db.Handler, name string) (bool, error)\n}\n"
  },
  {
    "path": "pkg/store/settings.go",
    "content": "package store\n\nimport (\n\t\"context\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/access\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n)\n\n// SettingStore is an interface for managing settings.\ntype SettingStore interface {\n\tGetAnonAccess(ctx context.Context, h db.Handler) (access.AccessLevel, error)\n\tSetAnonAccess(ctx context.Context, h db.Handler, level access.AccessLevel) error\n\tGetAllowKeylessAccess(ctx context.Context, h db.Handler) (bool, error)\n\tSetAllowKeylessAccess(ctx context.Context, h db.Handler, allow bool) error\n}\n"
  },
  {
    "path": "pkg/store/store.go",
    "content": "package store\n\n// Store is an interface for managing repositories, users, and settings.\ntype Store interface {\n\tRepositoryStore\n\tUserStore\n\tCollaboratorStore\n\tSettingStore\n\tLFSStore\n\tAccessTokenStore\n\tWebhookStore\n}\n"
  },
  {
    "path": "pkg/store/user.go",
    "content": "package store\n\nimport (\n\t\"context\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db/models\"\n\t\"golang.org/x/crypto/ssh\"\n)\n\n// UserStore is an interface for managing users.\ntype UserStore interface {\n\tGetUserByID(ctx context.Context, h db.Handler, id int64) (models.User, error)\n\tFindUserByUsername(ctx context.Context, h db.Handler, username string) (models.User, error)\n\tFindUserByPublicKey(ctx context.Context, h db.Handler, pk ssh.PublicKey) (models.User, error)\n\tFindUserByAccessToken(ctx context.Context, h db.Handler, token string) (models.User, error)\n\tGetAllUsers(ctx context.Context, h db.Handler) ([]models.User, error)\n\tCreateUser(ctx context.Context, h db.Handler, username string, isAdmin bool, pks []ssh.PublicKey) error\n\tDeleteUserByUsername(ctx context.Context, h db.Handler, username string) error\n\tSetUsernameByUsername(ctx context.Context, h db.Handler, username string, newUsername string) error\n\tSetAdminByUsername(ctx context.Context, h db.Handler, username string, isAdmin bool) error\n\tAddPublicKeyByUsername(ctx context.Context, h db.Handler, username string, pk ssh.PublicKey) error\n\tRemovePublicKeyByUsername(ctx context.Context, h db.Handler, username string, pk ssh.PublicKey) error\n\tListPublicKeysByUserID(ctx context.Context, h db.Handler, id int64) ([]ssh.PublicKey, error)\n\tListPublicKeysByUsername(ctx context.Context, h db.Handler, username string) ([]ssh.PublicKey, error)\n\tSetUserPassword(ctx context.Context, h db.Handler, userID int64, password string) error\n\tSetUserPasswordByUsername(ctx context.Context, h db.Handler, username string, password string) error\n}\n"
  },
  {
    "path": "pkg/store/webhooks.go",
    "content": "package store\n\nimport (\n\t\"context\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db/models\"\n\t\"github.com/google/uuid\"\n)\n\n// WebhookStore is an interface for managing webhooks.\ntype WebhookStore interface {\n\t// GetWebhookByID returns a webhook by its ID.\n\tGetWebhookByID(ctx context.Context, h db.Handler, repoID int64, id int64) (models.Webhook, error)\n\t// GetWebhooksByRepoID returns all webhooks for a repository.\n\tGetWebhooksByRepoID(ctx context.Context, h db.Handler, repoID int64) ([]models.Webhook, error)\n\t// GetWebhooksByRepoIDWhereEvent returns all webhooks for a repository where event is in the events.\n\tGetWebhooksByRepoIDWhereEvent(ctx context.Context, h db.Handler, repoID int64, events []int) ([]models.Webhook, error)\n\t// CreateWebhook creates a webhook.\n\tCreateWebhook(ctx context.Context, h db.Handler, repoID int64, url string, secret string, contentType int, active bool) (int64, error)\n\t// UpdateWebhookByID updates a webhook by its ID.\n\tUpdateWebhookByID(ctx context.Context, h db.Handler, repoID int64, id int64, url string, secret string, contentType int, active bool) error\n\t// DeleteWebhookByID deletes a webhook by its ID.\n\tDeleteWebhookByID(ctx context.Context, h db.Handler, id int64) error\n\t// DeleteWebhookForRepoByID deletes a webhook for a repository by its ID.\n\tDeleteWebhookForRepoByID(ctx context.Context, h db.Handler, repoID int64, id int64) error\n\n\t// GetWebhookEventByID returns a webhook event by its ID.\n\tGetWebhookEventByID(ctx context.Context, h db.Handler, id int64) (models.WebhookEvent, error)\n\t// GetWebhookEventsByWebhookID returns all webhook events for a webhook.\n\tGetWebhookEventsByWebhookID(ctx context.Context, h db.Handler, webhookID int64) ([]models.WebhookEvent, error)\n\t// CreateWebhookEvents creates webhook events for a webhook.\n\tCreateWebhookEvents(ctx context.Context, h db.Handler, webhookID int64, events []int) error\n\t// DeleteWebhookEventsByWebhookID deletes all webhook events for a webhook.\n\tDeleteWebhookEventsByID(ctx context.Context, h db.Handler, ids []int64) error\n\n\t// GetWebhookDeliveryByID returns a webhook delivery by its ID.\n\tGetWebhookDeliveryByID(ctx context.Context, h db.Handler, webhookID int64, id uuid.UUID) (models.WebhookDelivery, error)\n\t// GetWebhookDeliveriesByWebhookID returns all webhook deliveries for a webhook.\n\tGetWebhookDeliveriesByWebhookID(ctx context.Context, h db.Handler, webhookID int64) ([]models.WebhookDelivery, error)\n\t// ListWebhookDeliveriesByWebhookID returns all webhook deliveries for a webhook.\n\t// This only returns the delivery ID, response status, and event.\n\tListWebhookDeliveriesByWebhookID(ctx context.Context, h db.Handler, webhookID int64) ([]models.WebhookDelivery, error)\n\t// CreateWebhookDelivery creates a webhook delivery.\n\tCreateWebhookDelivery(ctx context.Context, h db.Handler, id uuid.UUID, webhookID int64, event int, url string, method string, requestError error, requestHeaders string, requestBody string, responseStatus int, responseHeaders string, responseBody string) error\n\t// DeleteWebhookDeliveryByID deletes a webhook delivery by its ID.\n\tDeleteWebhookDeliveryByID(ctx context.Context, h db.Handler, webhookID int64, id uuid.UUID) error\n}\n"
  },
  {
    "path": "pkg/sync/workqueue.go",
    "content": "package sync\n\nimport (\n\t\"context\"\n\t\"sync\"\n\n\t\"golang.org/x/sync/semaphore\"\n)\n\n// WorkPool is a pool of work to be done.\ntype WorkPool struct {\n\tworkers int\n\twork    sync.Map\n\tsem     *semaphore.Weighted\n\tctx     context.Context\n\tlogger  func(string, ...interface{})\n}\n\n// WorkPoolOption is a function that configures a WorkPool.\ntype WorkPoolOption func(*WorkPool)\n\n// WithWorkPoolLogger sets the logger to use.\nfunc WithWorkPoolLogger(logger func(string, ...interface{})) WorkPoolOption {\n\treturn func(wq *WorkPool) {\n\t\twq.logger = logger\n\t}\n}\n\n// NewWorkPool creates a new work pool. The workers argument specifies the\n// number of concurrent workers to run the work.\n// The queue will chunk the work into batches of workers size.\nfunc NewWorkPool(ctx context.Context, workers int, opts ...WorkPoolOption) *WorkPool {\n\twq := &WorkPool{\n\t\tworkers: workers,\n\t\tctx:     ctx,\n\t}\n\n\tfor _, opt := range opts {\n\t\topt(wq)\n\t}\n\n\tif wq.workers <= 0 {\n\t\twq.workers = 1\n\t}\n\n\twq.sem = semaphore.NewWeighted(int64(wq.workers))\n\n\treturn wq\n}\n\n// Run starts the workers and waits for them to finish.\nfunc (wq *WorkPool) Run() {\n\twq.work.Range(func(key, value any) bool {\n\t\tid := key.(string)\n\t\tfn := value.(func())\n\t\tif err := wq.sem.Acquire(wq.ctx, 1); err != nil {\n\t\t\twq.logf(\"workpool: %v\", err)\n\t\t\treturn false\n\t\t}\n\n\t\tgo func(id string, fn func()) {\n\t\t\tdefer wq.sem.Release(1)\n\t\t\tfn()\n\t\t\twq.work.Delete(id)\n\t\t}(id, fn)\n\n\t\treturn true\n\t})\n\n\tif err := wq.sem.Acquire(wq.ctx, int64(wq.workers)); err != nil {\n\t\twq.logf(\"workpool: %v\", err)\n\t}\n}\n\n// Add adds a new job to the pool.\n// If the job already exists, it is a no-op.\nfunc (wq *WorkPool) Add(id string, fn func()) {\n\tif _, ok := wq.work.Load(id); ok {\n\t\treturn\n\t}\n\twq.work.Store(id, fn)\n}\n\n// Status checks if a job is in the queue.\nfunc (wq *WorkPool) Status(id string) bool {\n\t_, ok := wq.work.Load(id)\n\treturn ok\n}\n\nfunc (wq *WorkPool) logf(format string, args ...interface{}) {\n\tif wq.logger != nil {\n\t\twq.logger(format, args...)\n\t}\n}\n"
  },
  {
    "path": "pkg/sync/workqueue_test.go",
    "content": "package sync\n\nimport (\n\t\"context\"\n\t\"strconv\"\n\t\"sync\"\n\t\"testing\"\n)\n\nfunc TestWorkPool(t *testing.T) {\n\tmtx := &sync.Mutex{}\n\tvalues := make([]int, 0)\n\twp := NewWorkPool(context.Background(), 3)\n\tfor i := 0; i < 10; i++ {\n\t\tid := strconv.Itoa(i)\n\t\ti := i\n\t\twp.Add(id, func() {\n\t\t\tmtx.Lock()\n\t\t\tvalues = append(values, i)\n\t\t\tmtx.Unlock()\n\t\t})\n\t}\n\twp.Run()\n\n\tif len(values) != 10 {\n\t\tt.Errorf(\"expected 10 values, got %d, %v\", len(values), values)\n\t}\n\n\tfor i := range values {\n\t\tid := strconv.Itoa(i)\n\t\tif wp.Status(id) {\n\t\t\tt.Errorf(\"expected %s to be false\", id)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/task/manager.go",
    "content": "package task\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"sync\"\n\t\"sync/atomic\"\n)\n\nvar (\n\t// ErrNotFound is returned when a process is not found.\n\tErrNotFound = errors.New(\"task not found\")\n\n\t// ErrAlreadyStarted is returned when a process is already started.\n\tErrAlreadyStarted = errors.New(\"task already started\")\n)\n\n// Task is a task that can be started and stopped.\ntype Task struct {\n\tid      string\n\tfn      func(context.Context) error\n\tstarted atomic.Bool\n\tctx     context.Context\n\tcancel  context.CancelFunc\n\terr     error\n}\n\n// Manager manages tasks.\ntype Manager struct {\n\tm   sync.Map\n\tctx context.Context\n}\n\n// NewManager returns a new task manager.\nfunc NewManager(ctx context.Context) *Manager {\n\treturn &Manager{\n\t\tm:   sync.Map{},\n\t\tctx: ctx,\n\t}\n}\n\n// Add adds a task to the manager.\n// If the process already exists, it is a no-op.\nfunc (m *Manager) Add(id string, fn func(context.Context) error) {\n\tif m.Exists(id) {\n\t\treturn\n\t}\n\n\tctx, cancel := context.WithCancel(m.ctx)\n\tm.m.Store(id, &Task{\n\t\tid:     id,\n\t\tfn:     fn,\n\t\tctx:    ctx,\n\t\tcancel: cancel,\n\t})\n}\n\n// Stop stops the task and removes it from the manager.\nfunc (m *Manager) Stop(id string) error {\n\tv, ok := m.m.Load(id)\n\tif !ok {\n\t\treturn ErrNotFound\n\t}\n\n\tp := v.(*Task)\n\tp.cancel()\n\n\tm.m.Delete(id)\n\treturn nil\n}\n\n// Exists checks if a task exists.\nfunc (m *Manager) Exists(id string) bool {\n\t_, ok := m.m.Load(id)\n\treturn ok\n}\n\n// Run starts the task if it exists.\n// Otherwise, it waits for the process to finish.\nfunc (m *Manager) Run(id string, done chan<- error) {\n\tv, ok := m.m.Load(id)\n\tif !ok {\n\t\tdone <- ErrNotFound\n\t\treturn\n\t}\n\n\tp := v.(*Task)\n\tif p.started.Load() {\n\t\t<-p.ctx.Done()\n\t\tif p.err != nil {\n\t\t\tdone <- p.err\n\t\t\treturn\n\t\t}\n\n\t\tdone <- p.ctx.Err()\n\t}\n\n\tp.started.Store(true)\n\tm.m.Store(id, p)\n\tdefer p.cancel()\n\tdefer m.m.Delete(id)\n\n\terrc := make(chan error, 1)\n\tgo func(ctx context.Context) {\n\t\terrc <- p.fn(ctx)\n\t}(p.ctx)\n\n\tselect {\n\tcase <-m.ctx.Done():\n\t\tdone <- m.ctx.Err()\n\tcase err := <-errc:\n\t\tp.err = err\n\t\tm.m.Store(id, p)\n\t\tdone <- err\n\t}\n}\n"
  },
  {
    "path": "pkg/test/test.go",
    "content": "package test\n\nimport (\n\t\"net\"\n\t\"sync\"\n)\n\nvar (\n\tused = map[int]struct{}{}\n\tlock sync.Mutex\n)\n\n// RandomPort returns a random port number.\n// This is mainly used for testing.\nfunc RandomPort() int {\n\taddr, _ := net.Listen(\"tcp\", \":0\") //nolint:gosec,noctx\n\t_ = addr.Close()\n\tport := addr.Addr().(*net.TCPAddr).Port\n\tlock.Lock()\n\n\tif _, ok := used[port]; ok {\n\t\tlock.Unlock()\n\t\treturn RandomPort()\n\t}\n\n\tused[port] = struct{}{}\n\tlock.Unlock()\n\treturn port\n}\n"
  },
  {
    "path": "pkg/ui/common/common.go",
    "content": "package common\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"charm.land/log/v2\"\n\t\"github.com/alecthomas/chroma/v2/lexers\"\n\t\"github.com/charmbracelet/soft-serve/git\"\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/keymap\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/styles\"\n\t\"github.com/charmbracelet/ssh\"\n\tzone \"github.com/lrstanley/bubblezone/v2\"\n)\n\ntype contextKey struct {\n\tname string\n}\n\n// Keys to use for context.Context.\nvar (\n\tConfigKey = &contextKey{\"config\"}\n\tRepoKey   = &contextKey{\"repo\"}\n)\n\n// Common is a struct all components should embed.\ntype Common struct {\n\tctx           context.Context\n\tWidth, Height int\n\tStyles        *styles.Styles\n\tKeyMap        *keymap.KeyMap\n\tZone          *zone.Manager\n\tLogger        *log.Logger\n\tHideCloneCmd  bool\n}\n\n// NewCommon returns a new Common struct.\nfunc NewCommon(ctx context.Context, width, height int) Common {\n\tif ctx == nil {\n\t\tctx = context.TODO()\n\t}\n\treturn Common{\n\t\tctx:    ctx,\n\t\tWidth:  width,\n\t\tHeight: height,\n\t\tStyles: styles.DefaultStyles(),\n\t\tKeyMap: keymap.DefaultKeyMap(),\n\t\tZone:   zone.New(),\n\t\tLogger: log.FromContext(ctx).WithPrefix(\"ui\"),\n\t}\n}\n\n// SetValue sets a value in the context.\nfunc (c *Common) SetValue(key, value interface{}) {\n\tc.ctx = context.WithValue(c.ctx, key, value)\n}\n\n// SetSize sets the width and height of the common struct.\nfunc (c *Common) SetSize(width, height int) {\n\tc.Width = width\n\tc.Height = height\n}\n\n// Context returns the context.\nfunc (c *Common) Context() context.Context {\n\treturn c.ctx\n}\n\n// Config returns the server config.\nfunc (c *Common) Config() *config.Config {\n\treturn config.FromContext(c.ctx)\n}\n\n// Backend returns the Soft Serve backend.\nfunc (c *Common) Backend() *backend.Backend {\n\treturn backend.FromContext(c.ctx)\n}\n\n// Repo returns the repository.\nfunc (c *Common) Repo() *git.Repository {\n\tv := c.ctx.Value(RepoKey)\n\tif r, ok := v.(*git.Repository); ok {\n\t\treturn r\n\t}\n\treturn nil\n}\n\n// PublicKey returns the public key.\nfunc (c *Common) PublicKey() ssh.PublicKey {\n\tv := c.ctx.Value(ssh.ContextKeyPublicKey)\n\tif p, ok := v.(ssh.PublicKey); ok {\n\t\treturn p\n\t}\n\treturn nil\n}\n\n// CloneCmd returns the clone command string.\nfunc (c *Common) CloneCmd(publicURL, name string) string {\n\tif c.HideCloneCmd {\n\t\treturn \"\"\n\t}\n\treturn fmt.Sprintf(\"git clone %s\", RepoURL(publicURL, name))\n}\n\n// IsFileMarkdown returns true if the file is markdown.\n// It uses chroma lexers to analyze and determine the language.\nfunc IsFileMarkdown(content, ext string) bool {\n\tvar lang string\n\tlexer := lexers.Match(ext)\n\tif lexer == nil {\n\t\tlexer = lexers.Analyse(content)\n\t}\n\tif lexer != nil && lexer.Config() != nil {\n\t\tlang = lexer.Config().Name\n\t}\n\treturn lang == \"markdown\"\n}\n\n// ScrollPercent returns a string representing the scroll percentage of the\n// viewport.\nfunc ScrollPercent(position int) string {\n\treturn fmt.Sprintf(\"≡ %d%%\", position)\n}\n"
  },
  {
    "path": "pkg/ui/common/common_test.go",
    "content": "package common_test\n\nimport (\n\t\"testing\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/common\"\n)\n\nfunc TestIsFileMarkdown(t *testing.T) {\n\tcases := []struct {\n\t\tname     string\n\t\tfilename string\n\t\tcontent  string // XXX: chroma doesn't correctly analyze mk files\n\t\tisMkd    bool\n\t}{\n\t\t{\"simple\", \"README.md\", \"\", true},\n\t\t{\"empty\", \"\", \"\", false},\n\t\t{\"no extension\", \"README\", \"\", false},\n\t\t{\"weird extension\", \"README.foo\", \"\", false},\n\t\t{\"long ext\", \"README.markdown\", \"\", true},\n\t}\n\n\tfor _, c := range cases {\n\t\tt.Run(c.name, func(t *testing.T) {\n\t\t\tif got := common.IsFileMarkdown(c.content, c.filename); got != c.isMkd {\n\t\t\t\tt.Errorf(\"IsFileMarkdown(%q, %q) = %v, want %v\", c.content, c.filename, got, c.isMkd)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/ui/common/component.go",
    "content": "package common\n\nimport (\n\t\"charm.land/bubbles/v2/help\"\n\ttea \"charm.land/bubbletea/v2\"\n)\n\n// Model represents a simple UI model.\ntype Model interface {\n\tInit() tea.Cmd\n\tUpdate(tea.Msg) (Model, tea.Cmd)\n\tView() string\n}\n\n// Component represents a Bubble Tea model that implements a SetSize function.\ntype Component interface {\n\tModel\n\thelp.KeyMap\n\tSetSize(width, height int)\n}\n\n// TabComponenet represents a model that is mounted to a tab.\n// TODO: find a better name\ntype TabComponent interface {\n\tComponent\n\n\t// StatusBarValue returns the status bar value component.\n\tStatusBarValue() string\n\n\t// StatusBarInfo returns the status bar info component.\n\tStatusBarInfo() string\n\n\t// SpinnerID returns the ID of the spinner.\n\tSpinnerID() int\n\n\t// TabName returns the name of the tab.\n\tTabName() string\n\n\t// Path returns the hierarchical path of the tab.\n\tPath() string\n}\n"
  },
  {
    "path": "pkg/ui/common/error.go",
    "content": "package common\n\nimport (\n\t\"errors\"\n\n\ttea \"charm.land/bubbletea/v2\"\n)\n\n// ErrMissingRepo indicates that the requested repository could not be found.\nvar ErrMissingRepo = errors.New(\"missing repo\")\n\n// ErrorMsg is a Bubble Tea message that represents an error.\ntype ErrorMsg error\n\n// ErrorCmd returns an ErrorMsg from error.\nfunc ErrorCmd(err error) tea.Cmd {\n\treturn func() tea.Msg {\n\t\treturn ErrorMsg(err)\n\t}\n}\n"
  },
  {
    "path": "pkg/ui/common/format.go",
    "content": "package common\n\nimport (\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\n\tgansi \"charm.land/glamour/v2/ansi\"\n\t\"github.com/alecthomas/chroma/v2/lexers\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/styles\"\n)\n\n// FormatLineNumber adds line numbers to a string.\nfunc FormatLineNumber(styles *styles.Styles, s string, color bool) (string, int) {\n\tlines := strings.Split(s, \"\\n\")\n\t// NB: len() is not a particularly safe way to count string width (because\n\t// it's counting bytes instead of runes) but in this case it's okay\n\t// because we're only dealing with digits, which are one byte each.\n\tmll := len(fmt.Sprintf(\"%d\", len(lines)))\n\tfor i, l := range lines {\n\t\tdigit := fmt.Sprintf(\"%*d\", mll, i+1)\n\t\tbar := \"│\"\n\t\tif color {\n\t\t\tdigit = styles.Code.LineDigit.Render(digit)\n\t\t\tbar = styles.Code.LineBar.Render(bar)\n\t\t}\n\t\tif i < len(lines)-1 || len(l) != 0 {\n\t\t\t// If the final line was a newline we'll get an empty string for\n\t\t\t// the final line, so drop the newline altogether.\n\t\t\tlines[i] = fmt.Sprintf(\" %s %s %s\", digit, bar, l)\n\t\t}\n\t}\n\treturn strings.Join(lines, \"\\n\"), mll\n}\n\n// FormatHighlight adds syntax highlighting to a string.\nfunc FormatHighlight(p, c string) (string, error) {\n\tzero := uint(0)\n\tlang := \"\"\n\tlexer := lexers.Match(p)\n\tif lexer != nil && lexer.Config() != nil {\n\t\tlang = lexer.Config().Name\n\t}\n\tformatter := &gansi.CodeBlockElement{\n\t\tCode:     c,\n\t\tLanguage: lang,\n\t}\n\tr := strings.Builder{}\n\tstyles := StyleConfig()\n\tstyles.CodeBlock.Margin = &zero\n\trctx := StyleRendererWithStyles(styles)\n\terr := formatter.Render(&r, rctx)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn r.String(), nil\n}\n\n// UnquoteFilename unquotes a filename.\n// When Git is with \"core.quotePath\" set to \"true\" (default), it will quote\n// the filename with double quotes if it contains control characters or unicode.\n// this function will unquote the filename.\nfunc UnquoteFilename(s string) string {\n\tname := s\n\tif n, err := strconv.Unquote(`\"` + s + `\"`); err == nil {\n\t\tname = n\n\t}\n\n\tname = strconv.Quote(name)\n\treturn strings.Trim(name, `\"`)\n}\n"
  },
  {
    "path": "pkg/ui/common/style.go",
    "content": "package common\n\nimport (\n\tgansi \"charm.land/glamour/v2/ansi\"\n\t\"charm.land/glamour/v2/styles\"\n\t\"github.com/charmbracelet/colorprofile\"\n)\n\n// DefaultColorProfile is the default color profile used by the SSH server.\nvar DefaultColorProfile = colorprofile.ANSI256\n\nfunc strptr(s string) *string {\n\treturn &s\n}\n\n// StyleConfig returns the default Glamour style configuration.\nfunc StyleConfig() gansi.StyleConfig {\n\tnoColor := strptr(\"\")\n\ts := styles.DarkStyleConfig\n\t// This fixes an issue with the default style config. For example\n\t// highlighting empty spaces with red in Dockerfile type.\n\ts.CodeBlock.Chroma.Error.BackgroundColor = noColor\n\treturn s\n}\n\n// StyleRenderer returns a new Glamour renderer.\nfunc StyleRenderer() gansi.RenderContext {\n\treturn StyleRendererWithStyles(StyleConfig())\n}\n\n// StyleRendererWithStyles returns a new Glamour renderer.\nfunc StyleRendererWithStyles(styles gansi.StyleConfig) gansi.RenderContext {\n\treturn gansi.NewRenderContext(gansi.Options{\n\t\tStyles: styles,\n\t})\n}\n"
  },
  {
    "path": "pkg/ui/common/utils.go",
    "content": "package common\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/utils\"\n\t\"github.com/muesli/reflow/truncate\"\n)\n\n// TruncateString is a convenient wrapper around truncate.TruncateString.\nfunc TruncateString(s string, max int) string { //nolint:revive\n\tif max < 0 {\n\t\tmax = 0 //nolint:revive\n\t}\n\treturn truncate.StringWithTail(s, uint(max), \"…\") //nolint:gosec\n}\n\n// RepoURL returns the URL of the repository.\nfunc RepoURL(publicURL, name string) string {\n\tname = utils.SanitizeRepo(name) + \".git\"\n\turl, err := url.Parse(publicURL)\n\tif err == nil {\n\t\tswitch url.Scheme {\n\t\tcase \"ssh\":\n\t\t\tport := url.Port()\n\t\t\tif port == \"\" || port == \"22\" {\n\t\t\t\treturn fmt.Sprintf(\"git@%s:%s\", url.Hostname(), name)\n\t\t\t}\n\t\t\treturn fmt.Sprintf(\"ssh://%s:%s/%s\", url.Hostname(), url.Port(), name)\n\t\t}\n\t}\n\n\treturn fmt.Sprintf(\"%s/%s\", publicURL, name)\n}\n"
  },
  {
    "path": "pkg/ui/components/code/code.go",
    "content": "package code\n\nimport (\n\t\"math\"\n\t\"strings\"\n\t\"sync\"\n\n\ttea \"charm.land/bubbletea/v2\"\n\t\"charm.land/glamour/v2\"\n\tgansi \"charm.land/glamour/v2/ansi\"\n\t\"charm.land/lipgloss/v2\"\n\t\"github.com/alecthomas/chroma/v2/lexers\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/common\"\n\tvp \"github.com/charmbracelet/soft-serve/pkg/ui/components/viewport\"\n)\n\nconst (\n\tdefaultTabWidth        = 4\n\tdefaultSideNotePercent = 0.3\n)\n\n// Code is a code snippet.\ntype Code struct {\n\t*vp.Viewport\n\tcommon        common.Common\n\tsidenote      string\n\tcontent       string\n\textension     string\n\trenderContext gansi.RenderContext\n\trenderMutex   sync.Mutex\n\tstyleConfig   gansi.StyleConfig\n\n\tSideNotePercent float64\n\tTabWidth        int\n\tShowLineNumber  bool\n\tNoContentStyle  lipgloss.Style\n\tUseGlamour      bool\n}\n\n// New returns a new Code.\nfunc New(c common.Common, content, extension string) *Code {\n\tr := &Code{\n\t\tcommon:          c,\n\t\tcontent:         content,\n\t\textension:       extension,\n\t\tTabWidth:        defaultTabWidth,\n\t\tSideNotePercent: defaultSideNotePercent,\n\t\tViewport:        vp.New(c),\n\t\tNoContentStyle:  c.Styles.NoContent.SetString(\"No Content.\"),\n\t}\n\tst := common.StyleConfig()\n\tr.styleConfig = st\n\tr.renderContext = common.StyleRendererWithStyles(st)\n\tr.SetSize(c.Width, c.Height)\n\treturn r\n}\n\n// SetSize implements common.Component.\nfunc (r *Code) SetSize(width, height int) {\n\tr.common.SetSize(width, height)\n\tr.Viewport.SetSize(width, height)\n}\n\n// SetContent sets the content of the Code.\nfunc (r *Code) SetContent(c, ext string) tea.Cmd {\n\tr.content = c\n\tr.extension = ext\n\treturn r.Init()\n}\n\n// SetSideNote sets the sidenote of the Code.\nfunc (r *Code) SetSideNote(s string) tea.Cmd {\n\tr.sidenote = s\n\treturn r.Init()\n}\n\n// Init implements tea.Model.\nfunc (r *Code) Init() tea.Cmd {\n\t// XXX: We probably won't need the GetHorizontalFrameSize margin\n\t// subtraction if we get the new viewport soft wrapping to play nicely with\n\t// Glamour. This also introduces a bug where when it soft wraps, the\n\t// viewport scrolls left/right for 2 columns on each side of the screen.\n\tw := r.common.Width - r.common.Styles.App.GetHorizontalFrameSize()\n\tcontent := r.content\n\tif content == \"\" {\n\t\tr.Viewport.Model.SetContent(r.NoContentStyle.String())\n\t\treturn nil\n\t}\n\n\t// FIXME chroma & glamour might break wrapping when using tabs since tab\n\t// width depends on the terminal. This is a workaround to replace tabs with\n\t// 4-spaces.\n\tcontent = strings.ReplaceAll(content, \"\\t\", strings.Repeat(\" \", r.TabWidth))\n\n\tif r.UseGlamour && common.IsFileMarkdown(content, r.extension) {\n\t\tmd, err := r.glamourize(w, content)\n\t\tif err != nil {\n\t\t\treturn common.ErrorCmd(err)\n\t\t}\n\t\tcontent = md\n\t} else {\n\t\tf, err := r.renderFile(r.extension, content)\n\t\tif err != nil {\n\t\t\treturn common.ErrorCmd(err)\n\t\t}\n\t\tcontent = f\n\t\tif r.ShowLineNumber {\n\t\t\tvar ml int\n\t\t\tcontent, ml = common.FormatLineNumber(r.common.Styles, content, true)\n\t\t\tw -= ml\n\t\t}\n\t}\n\n\tif r.sidenote != \"\" {\n\t\tlines := strings.Split(r.sidenote, \"\\n\")\n\t\tsideNoteWidth := int(math.Ceil(float64(r.Model.Width()) * r.SideNotePercent))\n\t\tfor i, l := range lines {\n\t\t\tlines[i] = common.TruncateString(l, sideNoteWidth)\n\t\t}\n\t\tcontent = lipgloss.JoinHorizontal(lipgloss.Top, strings.Join(lines, \"\\n\"), content)\n\t}\n\n\t// Fix styles after hard wrapping\n\t// https://github.com/muesli/reflow/issues/43\n\t//\n\t// TODO: solve this upstream in Glamour/Reflow.\n\tcontent = lipgloss.NewStyle().Width(w).Render(content)\n\n\tr.Viewport.Model.SetContent(content)\n\n\treturn nil\n}\n\n// Update implements tea.Model.\nfunc (r *Code) Update(msg tea.Msg) (common.Model, tea.Cmd) {\n\tcmds := make([]tea.Cmd, 0)\n\tswitch msg.(type) {\n\tcase tea.WindowSizeMsg:\n\t\t// Recalculate content width and line wrap.\n\t\tcmds = append(cmds, r.Init())\n\t}\n\tv, cmd := r.Viewport.Update(msg)\n\tr.Viewport = v.(*vp.Viewport)\n\tif cmd != nil {\n\t\tcmds = append(cmds, cmd)\n\t}\n\treturn r, tea.Batch(cmds...)\n}\n\n// View implements tea.View.\nfunc (r *Code) View() string {\n\treturn r.Viewport.View()\n}\n\n// GotoTop moves the viewport to the top of the log.\nfunc (r *Code) GotoTop() {\n\tr.Viewport.GotoTop()\n}\n\n// GotoBottom moves the viewport to the bottom of the log.\nfunc (r *Code) GotoBottom() {\n\tr.Viewport.GotoBottom()\n}\n\n// HalfViewDown moves the viewport down by half the viewport height.\nfunc (r *Code) HalfViewDown() {\n\tr.Viewport.HalfViewDown()\n}\n\n// HalfViewUp moves the viewport up by half the viewport height.\nfunc (r *Code) HalfViewUp() {\n\tr.Viewport.HalfViewUp()\n}\n\n// ScrollPercent returns the viewport's scroll percentage.\nfunc (r *Code) ScrollPercent() float64 {\n\treturn r.Viewport.ScrollPercent()\n}\n\n// ScrollPosition returns the viewport's scroll position.\nfunc (r *Code) ScrollPosition() int {\n\tscroll := r.ScrollPercent() * 100\n\tif scroll < 0 || math.IsNaN(scroll) {\n\t\tscroll = 0\n\t}\n\treturn int(scroll)\n}\n\nfunc (r *Code) glamourize(w int, md string) (string, error) {\n\tr.renderMutex.Lock()\n\tdefer r.renderMutex.Unlock()\n\tif w > 120 {\n\t\tw = 120\n\t}\n\ttr, err := glamour.NewTermRenderer(\n\t\tglamour.WithStyles(r.styleConfig),\n\t\tglamour.WithWordWrap(w),\n\t)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tmdt, err := tr.Render(md)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn mdt, nil\n}\n\nfunc (r *Code) renderFile(path, content string) (string, error) {\n\tlexer := lexers.Match(path)\n\tif path == \"\" {\n\t\tlexer = lexers.Analyse(content)\n\t}\n\tlang := \"\"\n\tif lexer != nil && lexer.Config() != nil {\n\t\tlang = lexer.Config().Name\n\t}\n\n\tformatter := &gansi.CodeBlockElement{\n\t\tCode:     content,\n\t\tLanguage: lang,\n\t}\n\ts := strings.Builder{}\n\trc := r.renderContext\n\tif r.ShowLineNumber {\n\t\tst := common.StyleConfig()\n\t\tvar m uint\n\t\tst.CodeBlock.Margin = &m\n\t\trc = gansi.NewRenderContext(gansi.Options{\n\t\t\tStyles: st,\n\t\t})\n\t}\n\terr := formatter.Render(&s, rc)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn s.String(), nil\n}\n"
  },
  {
    "path": "pkg/ui/components/footer/footer.go",
    "content": "package footer\n\nimport (\n\t\"charm.land/bubbles/v2/help\"\n\t\"charm.land/bubbles/v2/key\"\n\ttea \"charm.land/bubbletea/v2\"\n\t\"charm.land/lipgloss/v2\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/common\"\n)\n\n// ToggleFooterMsg is a message sent to show/hide the footer.\ntype ToggleFooterMsg struct{}\n\n// Footer is a Bubble Tea model that displays help and other info.\ntype Footer struct {\n\tcommon common.Common\n\thelp   help.Model\n\tkeymap help.KeyMap\n}\n\n// New creates a new Footer.\nfunc New(c common.Common, keymap help.KeyMap) *Footer {\n\th := help.New()\n\th.Styles.ShortKey = c.Styles.HelpKey\n\th.Styles.ShortDesc = c.Styles.HelpValue\n\th.Styles.FullKey = c.Styles.HelpKey\n\th.Styles.FullDesc = c.Styles.HelpValue\n\tf := &Footer{\n\t\tcommon: c,\n\t\thelp:   h,\n\t\tkeymap: keymap,\n\t}\n\tf.SetSize(c.Width, c.Height)\n\treturn f\n}\n\n// SetSize implements common.Component.\nfunc (f *Footer) SetSize(width, height int) {\n\tf.common.SetSize(width, height)\n\tf.help.SetWidth(width -\n\t\tf.common.Styles.Footer.GetHorizontalFrameSize())\n}\n\n// Init implements tea.Model.\nfunc (f *Footer) Init() tea.Cmd {\n\treturn nil\n}\n\n// Update implements tea.Model.\nfunc (f *Footer) Update(_ tea.Msg) (common.Model, tea.Cmd) {\n\treturn f, nil\n}\n\n// View implements tea.Model.\nfunc (f *Footer) View() string {\n\tif f.keymap == nil {\n\t\treturn \"\"\n\t}\n\ts := f.common.Styles.Footer.\n\t\tWidth(f.common.Width)\n\thelpView := f.help.View(f.keymap)\n\treturn f.common.Zone.Mark(\n\t\t\"footer\",\n\t\ts.Render(helpView),\n\t)\n}\n\n// ShortHelp returns the short help key bindings.\nfunc (f *Footer) ShortHelp() []key.Binding {\n\treturn f.keymap.ShortHelp()\n}\n\n// FullHelp returns the full help key bindings.\nfunc (f *Footer) FullHelp() [][]key.Binding {\n\treturn f.keymap.FullHelp()\n}\n\n// ShowAll returns whether the full help is shown.\nfunc (f *Footer) ShowAll() bool {\n\treturn f.help.ShowAll\n}\n\n// SetShowAll sets whether the full help is shown.\nfunc (f *Footer) SetShowAll(show bool) {\n\tf.help.ShowAll = show\n}\n\n// Height returns the height of the footer.\nfunc (f *Footer) Height() int {\n\treturn lipgloss.Height(f.View())\n}\n\n// ToggleFooterCmd sends a ToggleFooterMsg to show/hide the help footer.\nfunc ToggleFooterCmd() tea.Msg {\n\treturn ToggleFooterMsg{}\n}\n"
  },
  {
    "path": "pkg/ui/components/header/header.go",
    "content": "package header\n\nimport (\n\t\"strings\"\n\n\ttea \"charm.land/bubbletea/v2\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/common\"\n)\n\n// Header represents a header component.\ntype Header struct {\n\tcommon common.Common\n\ttext   string\n}\n\n// New creates a new header component.\nfunc New(c common.Common, text string) *Header {\n\treturn &Header{\n\t\tcommon: c,\n\t\ttext:   text,\n\t}\n}\n\n// SetSize implements common.Component.\nfunc (h *Header) SetSize(width, height int) {\n\th.common.SetSize(width, height)\n}\n\n// Init implements tea.Model.\nfunc (h *Header) Init() tea.Cmd {\n\treturn nil\n}\n\n// Update implements tea.Model.\nfunc (h *Header) Update(_ tea.Msg) (common.Model, tea.Cmd) {\n\treturn h, nil\n}\n\n// View implements tea.Model.\nfunc (h *Header) View() string {\n\treturn h.common.Styles.ServerName.Render(strings.TrimSpace(h.text))\n}\n"
  },
  {
    "path": "pkg/ui/components/selector/selector.go",
    "content": "package selector\n\nimport (\n\t\"sync\"\n\n\t\"charm.land/bubbles/v2/key\"\n\t\"charm.land/bubbles/v2/list\"\n\ttea \"charm.land/bubbletea/v2\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/common\"\n)\n\n// Selector is a list of items that can be selected.\ntype Selector struct {\n\t*list.Model\n\tcommon      common.Common\n\tactive      int\n\tfilterState list.FilterState\n\n\t// XXX: we use a mutex to support concurrent access to the model. This is\n\t// needed to implement pagination for the Log component. list.Model does\n\t// not support item pagination so we hack it ourselves on top of\n\t// list.Model.\n\tmtx sync.RWMutex\n}\n\n// IdentifiableItem is an item that can be identified by a string. Implements\n// list.DefaultItem.\ntype IdentifiableItem interface {\n\tlist.DefaultItem\n\tID() string\n}\n\n// ItemDelegate is a wrapper around list.ItemDelegate.\ntype ItemDelegate interface {\n\tlist.ItemDelegate\n}\n\n// SelectMsg is a message that is sent when an item is selected.\ntype SelectMsg struct{ IdentifiableItem }\n\n// ActiveMsg is a message that is sent when an item is active but not selected.\ntype ActiveMsg struct{ IdentifiableItem }\n\n// New creates a new selector.\nfunc New(common common.Common, items []IdentifiableItem, delegate ItemDelegate) *Selector {\n\titms := make([]list.Item, len(items))\n\tfor i, item := range items {\n\t\titms[i] = item\n\t}\n\tl := list.New(itms, delegate, common.Width, common.Height)\n\tl.Styles.NoItems = common.Styles.NoContent\n\ts := &Selector{\n\t\tModel:  &l,\n\t\tcommon: common,\n\t}\n\ts.SetSize(common.Width, common.Height)\n\treturn s\n}\n\n// PerPage returns the number of items per page.\nfunc (s *Selector) PerPage() int {\n\ts.mtx.RLock()\n\tdefer s.mtx.RUnlock()\n\treturn s.Model.Paginator.PerPage\n}\n\n// SetPage sets the current page.\nfunc (s *Selector) SetPage(page int) {\n\ts.mtx.Lock()\n\tdefer s.mtx.Unlock()\n\ts.Model.Paginator.Page = page\n}\n\n// Page returns the current page.\nfunc (s *Selector) Page() int {\n\ts.mtx.RLock()\n\tdefer s.mtx.RUnlock()\n\treturn s.Model.Paginator.Page\n}\n\n// TotalPages returns the total number of pages.\nfunc (s *Selector) TotalPages() int {\n\ts.mtx.RLock()\n\tdefer s.mtx.RUnlock()\n\treturn s.Model.Paginator.TotalPages\n}\n\n// SetTotalPages sets the total number of pages given the number of items.\nfunc (s *Selector) SetTotalPages(items int) int {\n\ts.mtx.Lock()\n\tdefer s.mtx.Unlock()\n\treturn s.Model.Paginator.SetTotalPages(items)\n}\n\n// SelectedItem returns the currently selected item.\nfunc (s *Selector) SelectedItem() IdentifiableItem {\n\ts.mtx.RLock()\n\tdefer s.mtx.RUnlock()\n\titem := s.Model.SelectedItem()\n\ti, ok := item.(IdentifiableItem)\n\tif !ok {\n\t\treturn nil\n\t}\n\treturn i\n}\n\n// Select selects the item at the given index.\nfunc (s *Selector) Select(index int) {\n\ts.mtx.RLock()\n\tdefer s.mtx.RUnlock()\n\ts.Model.Select(index)\n}\n\n// SetShowTitle sets the show title flag.\nfunc (s *Selector) SetShowTitle(show bool) {\n\ts.mtx.Lock()\n\tdefer s.mtx.Unlock()\n\ts.Model.SetShowTitle(show)\n}\n\n// SetShowHelp sets the show help flag.\nfunc (s *Selector) SetShowHelp(show bool) {\n\ts.mtx.Lock()\n\tdefer s.mtx.Unlock()\n\ts.Model.SetShowHelp(show)\n}\n\n// SetShowStatusBar sets the show status bar flag.\nfunc (s *Selector) SetShowStatusBar(show bool) {\n\ts.mtx.Lock()\n\tdefer s.mtx.Unlock()\n\ts.Model.SetShowStatusBar(show)\n}\n\n// DisableQuitKeybindings disables the quit keybindings.\nfunc (s *Selector) DisableQuitKeybindings() {\n\ts.mtx.Lock()\n\tdefer s.mtx.Unlock()\n\ts.Model.DisableQuitKeybindings()\n}\n\n// SetShowFilter sets the show filter flag.\nfunc (s *Selector) SetShowFilter(show bool) {\n\ts.mtx.Lock()\n\tdefer s.mtx.Unlock()\n\ts.Model.SetShowFilter(show)\n}\n\n// SetShowPagination sets the show pagination flag.\nfunc (s *Selector) SetShowPagination(show bool) {\n\ts.mtx.Lock()\n\tdefer s.mtx.Unlock()\n\ts.Model.SetShowPagination(show)\n}\n\n// SetFilteringEnabled sets the filtering enabled flag.\nfunc (s *Selector) SetFilteringEnabled(enabled bool) {\n\ts.mtx.Lock()\n\tdefer s.mtx.Unlock()\n\ts.Model.SetFilteringEnabled(enabled)\n}\n\n// SetSize implements common.Component.\nfunc (s *Selector) SetSize(width, height int) {\n\ts.mtx.Lock()\n\tdefer s.mtx.Unlock()\n\ts.common.SetSize(width, height)\n\ts.Model.SetSize(width, height)\n}\n\n// SetItems sets the items in the selector.\nfunc (s *Selector) SetItems(items []IdentifiableItem) tea.Cmd {\n\tits := make([]list.Item, len(items))\n\tfor i, item := range items {\n\t\tits[i] = item\n\t}\n\ts.mtx.Lock()\n\tdefer s.mtx.Unlock()\n\treturn s.Model.SetItems(its)\n}\n\n// Index returns the index of the selected item.\nfunc (s *Selector) Index() int {\n\ts.mtx.RLock()\n\tdefer s.mtx.RUnlock()\n\treturn s.Model.Index()\n}\n\n// Items returns the items in the selector.\nfunc (s *Selector) Items() []list.Item {\n\ts.mtx.RLock()\n\tdefer s.mtx.RUnlock()\n\treturn s.Model.Items()\n}\n\n// VisibleItems returns all the visible items in the selector.\nfunc (s *Selector) VisibleItems() []list.Item {\n\ts.mtx.RLock()\n\tdefer s.mtx.RUnlock()\n\treturn s.Model.VisibleItems()\n}\n\n// FilterState returns the filter state.\nfunc (s *Selector) FilterState() list.FilterState {\n\ts.mtx.RLock()\n\tdefer s.mtx.RUnlock()\n\treturn s.Model.FilterState()\n}\n\n// CursorUp moves the cursor up.\nfunc (s *Selector) CursorUp() {\n\ts.mtx.Lock()\n\tdefer s.mtx.Unlock()\n\ts.Model.CursorUp()\n}\n\n// CursorDown moves the cursor down.\nfunc (s *Selector) CursorDown() {\n\ts.mtx.Lock()\n\tdefer s.mtx.Unlock()\n\ts.Model.CursorDown()\n}\n\n// Init implements tea.Model.\nfunc (s *Selector) Init() tea.Cmd {\n\treturn s.activeCmd\n}\n\n// Update implements tea.Model.\nfunc (s *Selector) Update(msg tea.Msg) (common.Model, tea.Cmd) {\n\tcmds := make([]tea.Cmd, 0)\n\tswitch msg := msg.(type) {\n\tcase tea.MouseClickMsg:\n\t\tm := msg.Mouse()\n\t\tswitch m.Button {\n\t\tcase tea.MouseWheelUp:\n\t\t\ts.CursorUp()\n\t\tcase tea.MouseWheelDown:\n\t\t\ts.CursorDown()\n\t\tcase tea.MouseLeft:\n\t\t\tcurIdx := s.Index()\n\t\t\tfor i, item := range s.Items() {\n\t\t\t\titem, _ := item.(IdentifiableItem)\n\t\t\t\t// Check each item to see if it's in bounds.\n\t\t\t\tif item != nil && s.common.Zone.Get(item.ID()).InBounds(msg) {\n\t\t\t\t\tif i == curIdx {\n\t\t\t\t\t\tcmds = append(cmds, s.SelectItemCmd)\n\t\t\t\t\t} else {\n\t\t\t\t\t\ts.Select(i)\n\t\t\t\t\t}\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\tcase tea.KeyPressMsg:\n\t\tfilterState := s.FilterState()\n\t\tswitch {\n\t\tcase key.Matches(msg, s.common.KeyMap.Help):\n\t\t\tif filterState == list.Filtering {\n\t\t\t\treturn s, tea.Batch(cmds...)\n\t\t\t}\n\t\tcase key.Matches(msg, s.common.KeyMap.Select):\n\t\t\tif filterState != list.Filtering {\n\t\t\t\tcmds = append(cmds, s.SelectItemCmd)\n\t\t\t}\n\t\t}\n\tcase list.FilterMatchesMsg:\n\t\tcmds = append(cmds, s.activeFilterCmd)\n\t}\n\tm, cmd := s.Model.Update(msg)\n\ts.mtx.Lock()\n\ts.Model = &m\n\ts.mtx.Unlock()\n\tif cmd != nil {\n\t\tcmds = append(cmds, cmd)\n\t}\n\t// Track filter state and update active item when filter state changes.\n\tfilterState := s.FilterState()\n\tif s.filterState != filterState {\n\t\tcmds = append(cmds, s.activeFilterCmd)\n\t}\n\ts.filterState = filterState\n\t// Send ActiveMsg when index change.\n\tif s.active != s.Index() {\n\t\tcmds = append(cmds, s.activeCmd)\n\t}\n\ts.active = s.Index()\n\treturn s, tea.Batch(cmds...)\n}\n\n// View implements tea.Model.\nfunc (s *Selector) View() string {\n\treturn s.Model.View()\n}\n\n// SelectItemCmd is a command that selects the currently active item.\nfunc (s *Selector) SelectItemCmd() tea.Msg {\n\treturn SelectMsg{s.SelectedItem()}\n}\n\nfunc (s *Selector) activeCmd() tea.Msg {\n\titem := s.SelectedItem()\n\treturn ActiveMsg{item}\n}\n\nfunc (s *Selector) activeFilterCmd() tea.Msg {\n\t// Here we use VisibleItems because when list.FilterMatchesMsg is sent,\n\t// VisibleItems is the only way to get the list of filtered items. The list\n\t// bubble should export something like list.FilterMatchesMsg.Items().\n\titems := s.VisibleItems()\n\tif len(items) == 0 {\n\t\treturn nil\n\t}\n\titem := items[0]\n\ti, ok := item.(IdentifiableItem)\n\tif !ok {\n\t\treturn nil\n\t}\n\treturn ActiveMsg{i}\n}\n"
  },
  {
    "path": "pkg/ui/components/statusbar/statusbar.go",
    "content": "package statusbar\n\nimport (\n\ttea \"charm.land/bubbletea/v2\"\n\t\"charm.land/lipgloss/v2\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/common\"\n\t\"github.com/charmbracelet/x/ansi\"\n)\n\n// Model is a status bar model.\ntype Model struct {\n\tcommon common.Common\n\tkey    string\n\tvalue  string\n\tinfo   string\n\textra  string\n}\n\n// New creates a new status bar component.\nfunc New(c common.Common) *Model {\n\ts := &Model{\n\t\tcommon: c,\n\t}\n\treturn s\n}\n\n// SetSize implements common.Component.\nfunc (s *Model) SetSize(width, height int) {\n\ts.common.Width = width\n\ts.common.Height = height\n}\n\n// SetStatus sets the status bar status.\nfunc (s *Model) SetStatus(key, value, info, extra string) {\n\tif key != \"\" {\n\t\ts.key = key\n\t}\n\tif value != \"\" {\n\t\ts.value = value\n\t}\n\tif info != \"\" {\n\t\ts.info = info\n\t}\n\tif extra != \"\" {\n\t\ts.extra = extra\n\t}\n}\n\n// Init implements tea.Model.\nfunc (s *Model) Init() tea.Cmd {\n\treturn nil\n}\n\n// Update implements tea.Model.\nfunc (s *Model) Update(msg tea.Msg) (common.Model, tea.Cmd) {\n\tswitch msg := msg.(type) {\n\tcase tea.WindowSizeMsg:\n\t\ts.SetSize(msg.Width, msg.Height)\n\t}\n\treturn s, nil\n}\n\n// View implements tea.Model.\nfunc (s *Model) View() string {\n\tst := s.common.Styles\n\tw := lipgloss.Width\n\thelp := s.common.Zone.Mark(\n\t\t\"repo-help\",\n\t\tst.StatusBarHelp.Render(\"? Help\"),\n\t)\n\tkey := st.StatusBarKey.Render(s.key)\n\tinfo := \"\"\n\tif s.info != \"\" {\n\t\tinfo = st.StatusBarInfo.Render(s.info)\n\t}\n\tbranch := st.StatusBarBranch.Render(s.extra)\n\tmaxWidth := s.common.Width - w(key) - w(info) - w(branch) - w(help)\n\tv := ansi.Truncate(s.value, maxWidth-st.StatusBarValue.GetHorizontalFrameSize(), \"…\")\n\tvalue := st.StatusBarValue.\n\t\tWidth(maxWidth).\n\t\tRender(v)\n\n\treturn lipgloss.NewStyle().MaxWidth(s.common.Width).\n\t\tRender(\n\t\t\tlipgloss.JoinHorizontal(lipgloss.Top,\n\t\t\t\tkey,\n\t\t\t\tvalue,\n\t\t\t\tinfo,\n\t\t\t\tbranch,\n\t\t\t\thelp,\n\t\t\t),\n\t\t)\n}\n"
  },
  {
    "path": "pkg/ui/components/tabs/tabs.go",
    "content": "package tabs\n\nimport (\n\t\"strings\"\n\n\ttea \"charm.land/bubbletea/v2\"\n\t\"charm.land/lipgloss/v2\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/common\"\n)\n\n// SelectTabMsg is a message that contains the index of the tab to select.\ntype SelectTabMsg int\n\n// ActiveTabMsg is a message that contains the index of the current active tab.\ntype ActiveTabMsg int\n\n// Tabs is bubbletea component that displays a list of tabs.\ntype Tabs struct {\n\tcommon       common.Common\n\ttabs         []string\n\tactiveTab    int\n\tTabSeparator lipgloss.Style\n\tTabInactive  lipgloss.Style\n\tTabActive    lipgloss.Style\n\tTabDot       lipgloss.Style\n\tUseDot       bool\n}\n\n// New creates a new Tabs component.\nfunc New(c common.Common, tabs []string) *Tabs {\n\tr := &Tabs{\n\t\tcommon:       c,\n\t\ttabs:         tabs,\n\t\tactiveTab:    0,\n\t\tTabSeparator: c.Styles.TabSeparator,\n\t\tTabInactive:  c.Styles.TabInactive,\n\t\tTabActive:    c.Styles.TabActive,\n\t}\n\treturn r\n}\n\n// SetSize implements common.Component.\nfunc (t *Tabs) SetSize(width, height int) {\n\tt.common.SetSize(width, height)\n}\n\n// Init implements tea.Model.\nfunc (t *Tabs) Init() tea.Cmd {\n\tt.activeTab = 0\n\treturn nil\n}\n\n// Update implements tea.Model.\nfunc (t *Tabs) Update(msg tea.Msg) (common.Model, tea.Cmd) {\n\tcmds := make([]tea.Cmd, 0)\n\tswitch msg := msg.(type) {\n\tcase tea.KeyPressMsg:\n\t\tswitch msg.String() {\n\t\tcase \"tab\":\n\t\t\tt.activeTab = (t.activeTab + 1) % len(t.tabs)\n\t\t\tcmds = append(cmds, t.activeTabCmd)\n\t\tcase \"shift+tab\":\n\t\t\tt.activeTab = (t.activeTab - 1 + len(t.tabs)) % len(t.tabs)\n\t\t\tcmds = append(cmds, t.activeTabCmd)\n\t\t}\n\tcase tea.MouseClickMsg:\n\t\tswitch msg.Button {\n\t\tcase tea.MouseLeft:\n\t\t\tfor i, tab := range t.tabs {\n\t\t\t\tif t.common.Zone.Get(tab).InBounds(msg) {\n\t\t\t\t\tt.activeTab = i\n\t\t\t\t\tcmds = append(cmds, t.activeTabCmd)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\tcase SelectTabMsg:\n\t\ttab := int(msg)\n\t\tif tab >= 0 && tab < len(t.tabs) {\n\t\t\tt.activeTab = int(msg)\n\t\t}\n\t}\n\treturn t, tea.Batch(cmds...)\n}\n\n// View implements tea.Model.\nfunc (t *Tabs) View() string {\n\ts := strings.Builder{}\n\tsep := t.TabSeparator\n\tfor i, tab := range t.tabs {\n\t\tstyle := t.TabInactive\n\t\tprefix := \"  \"\n\t\tif i == t.activeTab {\n\t\t\tstyle = t.TabActive\n\t\t\tprefix = t.TabDot.Render(\"• \")\n\t\t}\n\t\tif t.UseDot {\n\t\t\ts.WriteString(prefix)\n\t\t}\n\t\ts.WriteString(\n\t\t\tt.common.Zone.Mark(\n\t\t\t\ttab,\n\t\t\t\tstyle.Render(tab),\n\t\t\t),\n\t\t)\n\t\tif i != len(t.tabs)-1 {\n\t\t\ts.WriteString(sep.String())\n\t\t}\n\t}\n\treturn lipgloss.NewStyle().\n\t\tMaxWidth(t.common.Width).\n\t\tRender(s.String())\n}\n\nfunc (t *Tabs) activeTabCmd() tea.Msg {\n\treturn ActiveTabMsg(t.activeTab)\n}\n\n// SelectTabCmd is a bubbletea command that selects the tab at the given index.\nfunc SelectTabCmd(tab int) tea.Cmd {\n\treturn func() tea.Msg {\n\t\treturn SelectTabMsg(tab)\n\t}\n}\n"
  },
  {
    "path": "pkg/ui/components/viewport/viewport.go",
    "content": "package viewport\n\nimport (\n\t\"charm.land/bubbles/v2/key\"\n\t\"charm.land/bubbles/v2/viewport\"\n\ttea \"charm.land/bubbletea/v2\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/common\"\n)\n\n// Viewport represents a viewport component.\ntype Viewport struct {\n\tcommon common.Common\n\t*viewport.Model\n}\n\n// New returns a new Viewport.\nfunc New(c common.Common) *Viewport {\n\tvp := viewport.New()\n\tvp.SetWidth(c.Width)\n\tvp.SetHeight(c.Height)\n\tvp.MouseWheelEnabled = true\n\treturn &Viewport{\n\t\tcommon: c,\n\t\tModel:  &vp,\n\t}\n}\n\n// SetSize implements common.Component.\nfunc (v *Viewport) SetSize(width, height int) {\n\tv.common.SetSize(width, height)\n\tv.Model.SetWidth(width)\n\tv.Model.SetHeight(height)\n}\n\n// Init implements tea.Model.\nfunc (v *Viewport) Init() tea.Cmd {\n\treturn nil\n}\n\n// Update implements tea.Model.\nfunc (v *Viewport) Update(msg tea.Msg) (common.Model, tea.Cmd) {\n\tswitch msg := msg.(type) {\n\tcase tea.KeyPressMsg:\n\t\tswitch {\n\t\tcase key.Matches(msg, v.common.KeyMap.GotoTop):\n\t\t\tv.GotoTop()\n\t\tcase key.Matches(msg, v.common.KeyMap.GotoBottom):\n\t\t\tv.GotoBottom()\n\t\t}\n\t}\n\tvp, cmd := v.Model.Update(msg)\n\tv.Model = &vp\n\treturn v, cmd\n}\n\n// View implements tea.Model.\nfunc (v *Viewport) View() string {\n\treturn v.Model.View()\n}\n\n// SetContent sets the viewport's content.\nfunc (v *Viewport) SetContent(content string) {\n\tv.Model.SetContent(content)\n}\n\n// GotoTop moves the viewport to the top of the log.\nfunc (v *Viewport) GotoTop() {\n\tv.Model.GotoTop()\n}\n\n// GotoBottom moves the viewport to the bottom of the log.\nfunc (v *Viewport) GotoBottom() {\n\tv.Model.GotoBottom()\n}\n\n// HalfViewDown moves the viewport down by half the viewport height.\nfunc (v *Viewport) HalfViewDown() {\n\tv.Model.HalfPageDown()\n}\n\n// HalfViewUp moves the viewport up by half the viewport height.\nfunc (v *Viewport) HalfViewUp() {\n\tv.Model.HalfPageUp()\n}\n\n// ScrollPercent returns the viewport's scroll percentage.\nfunc (v *Viewport) ScrollPercent() float64 {\n\treturn v.Model.ScrollPercent()\n}\n"
  },
  {
    "path": "pkg/ui/keymap/keymap.go",
    "content": "package keymap\n\nimport \"charm.land/bubbles/v2/key\"\n\n// KeyMap is a map of key bindings for the UI.\ntype KeyMap struct {\n\tQuit       key.Binding\n\tUp         key.Binding\n\tDown       key.Binding\n\tUpDown     key.Binding\n\tLeftRight  key.Binding\n\tArrows     key.Binding\n\tGotoTop    key.Binding\n\tGotoBottom key.Binding\n\tSelect     key.Binding\n\tSection    key.Binding\n\tBack       key.Binding\n\tPrevPage   key.Binding\n\tNextPage   key.Binding\n\tHelp       key.Binding\n\n\tSelectItem key.Binding\n\tBackItem   key.Binding\n\n\tCopy key.Binding\n}\n\n// DefaultKeyMap returns the default key map.\nfunc DefaultKeyMap() *KeyMap {\n\tkm := new(KeyMap)\n\n\tkm.Quit = key.NewBinding(\n\t\tkey.WithKeys(\n\t\t\t\"q\",\n\t\t\t\"ctrl+c\",\n\t\t),\n\t\tkey.WithHelp(\n\t\t\t\"q\",\n\t\t\t\"quit\",\n\t\t),\n\t)\n\n\tkm.Up = key.NewBinding(\n\t\tkey.WithKeys(\n\t\t\t\"up\",\n\t\t\t\"k\",\n\t\t),\n\t\tkey.WithHelp(\n\t\t\t\"↑\",\n\t\t\t\"up\",\n\t\t),\n\t)\n\n\tkm.Down = key.NewBinding(\n\t\tkey.WithKeys(\n\t\t\t\"down\",\n\t\t\t\"j\",\n\t\t),\n\t\tkey.WithHelp(\n\t\t\t\"↓\",\n\t\t\t\"down\",\n\t\t),\n\t)\n\n\tkm.UpDown = key.NewBinding(\n\t\tkey.WithKeys(\n\t\t\t\"up\",\n\t\t\t\"down\",\n\t\t\t\"k\",\n\t\t\t\"j\",\n\t\t),\n\t\tkey.WithHelp(\n\t\t\t\"↑↓\",\n\t\t\t\"navigate\",\n\t\t),\n\t)\n\n\tkm.LeftRight = key.NewBinding(\n\t\tkey.WithKeys(\n\t\t\t\"left\",\n\t\t\t\"h\",\n\t\t\t\"right\",\n\t\t\t\"l\",\n\t\t),\n\t\tkey.WithHelp(\n\t\t\t\"←→\",\n\t\t\t\"navigate\",\n\t\t),\n\t)\n\n\tkm.Arrows = key.NewBinding(\n\t\tkey.WithKeys(\n\t\t\t\"up\",\n\t\t\t\"right\",\n\t\t\t\"down\",\n\t\t\t\"left\",\n\t\t\t\"k\",\n\t\t\t\"j\",\n\t\t\t\"h\",\n\t\t\t\"l\",\n\t\t),\n\t\tkey.WithHelp(\n\t\t\t\"↑←↓→\",\n\t\t\t\"navigate\",\n\t\t),\n\t)\n\n\tkm.GotoTop = key.NewBinding(\n\t\tkey.WithKeys(\n\t\t\t\"home\",\n\t\t\t\"g\",\n\t\t),\n\t\tkey.WithHelp(\n\t\t\t\"g/home\",\n\t\t\t\"goto top\",\n\t\t),\n\t)\n\n\tkm.GotoBottom = key.NewBinding(\n\t\tkey.WithKeys(\n\t\t\t\"end\",\n\t\t\t\"G\",\n\t\t),\n\t\tkey.WithHelp(\n\t\t\t\"G/end\",\n\t\t\t\"goto bottom\",\n\t\t),\n\t)\n\n\tkm.Select = key.NewBinding(\n\t\tkey.WithKeys(\n\t\t\t\"enter\",\n\t\t),\n\t\tkey.WithHelp(\n\t\t\t\"enter\",\n\t\t\t\"select\",\n\t\t),\n\t)\n\n\tkm.Section = key.NewBinding(\n\t\tkey.WithKeys(\n\t\t\t\"tab\",\n\t\t\t\"shift+tab\",\n\t\t),\n\t\tkey.WithHelp(\n\t\t\t\"tab\",\n\t\t\t\"section\",\n\t\t),\n\t)\n\n\tkm.Back = key.NewBinding(\n\t\tkey.WithKeys(\n\t\t\t\"esc\",\n\t\t),\n\t\tkey.WithHelp(\n\t\t\t\"esc\",\n\t\t\t\"back\",\n\t\t),\n\t)\n\n\tkm.PrevPage = key.NewBinding(\n\t\tkey.WithKeys(\n\t\t\t\"pgup\",\n\t\t\t\"b\",\n\t\t\t\"u\",\n\t\t),\n\t\tkey.WithHelp(\n\t\t\t\"pgup\",\n\t\t\t\"prev page\",\n\t\t),\n\t)\n\n\tkm.NextPage = key.NewBinding(\n\t\tkey.WithKeys(\n\t\t\t\"pgdown\",\n\t\t\t\"f\",\n\t\t\t\"d\",\n\t\t),\n\t\tkey.WithHelp(\n\t\t\t\"pgdn\",\n\t\t\t\"next page\",\n\t\t),\n\t)\n\n\tkm.Help = key.NewBinding(\n\t\tkey.WithKeys(\n\t\t\t\"?\",\n\t\t),\n\t\tkey.WithHelp(\n\t\t\t\"?\",\n\t\t\t\"toggle help\",\n\t\t),\n\t)\n\n\tkm.SelectItem = key.NewBinding(\n\t\tkey.WithKeys(\n\t\t\t\"l\",\n\t\t\t\"right\",\n\t\t),\n\t\tkey.WithHelp(\n\t\t\t\"→/l\",\n\t\t\t\"select\",\n\t\t),\n\t)\n\n\tkm.BackItem = key.NewBinding(\n\t\tkey.WithKeys(\n\t\t\t\"h\",\n\t\t\t\"left\",\n\t\t\t\"backspace\",\n\t\t),\n\t\tkey.WithHelp(\n\t\t\t\"←/h\",\n\t\t\t\"back\",\n\t\t),\n\t)\n\n\tkm.Copy = key.NewBinding(\n\t\tkey.WithKeys(\n\t\t\t\"c\",\n\t\t\t\"ctrl+c\",\n\t\t),\n\t\tkey.WithHelp(\n\t\t\t\"c\",\n\t\t\t\"copy text\",\n\t\t),\n\t)\n\n\treturn km\n}\n"
  },
  {
    "path": "pkg/ui/pages/repo/empty.go",
    "content": "package repo\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/common\"\n)\n\nfunc defaultEmptyRepoMsg(cfg *config.Config, repo string) string {\n\treturn fmt.Sprintf(`# Quick Start\n\nGet started by cloning this repository, add your files, commit, and push.\n\n## Clone this repository.\n\n`+\"```\"+`sh\ngit clone %[1]s\n`+\"```\"+`\n\n## Creating a new repository on the command line\n\n`+\"```\"+`sh\ntouch README.md\ngit init\ngit add README.md\ngit branch -M main\ngit commit -m \"first commit\"\ngit remote add origin %[1]s\ngit push -u origin main\n`+\"```\"+`\n\n## Pushing an existing repository from the command line\n\n`+\"```\"+`sh\ngit remote add origin %[1]s\ngit push -u origin main\n`+\"```\"+`\n`, common.RepoURL(cfg.SSH.PublicURL, repo))\n}\n"
  },
  {
    "path": "pkg/ui/pages/repo/files.go",
    "content": "package repo\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"charm.land/bubbles/v2/key\"\n\t\"charm.land/bubbles/v2/spinner\"\n\ttea \"charm.land/bubbletea/v2\"\n\tgitm \"github.com/aymanbagabas/git-module\"\n\t\"github.com/charmbracelet/soft-serve/git\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/common\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/components/code\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/components/selector\"\n)\n\ntype filesView int\n\nconst (\n\tfilesViewLoading filesView = iota\n\tfilesViewFiles\n\tfilesViewContent\n)\n\nvar (\n\terrNoFileSelected = errors.New(\"no file selected\")\n\terrBinaryFile     = errors.New(\"binary file\")\n\terrInvalidFile    = errors.New(\"invalid file\")\n)\n\nvar (\n\tlineNo = key.NewBinding(\n\t\tkey.WithKeys(\"l\"),\n\t\tkey.WithHelp(\"l\", \"toggle line numbers\"),\n\t)\n\tblameView = key.NewBinding(\n\t\tkey.WithKeys(\"b\"),\n\t\tkey.WithHelp(\"b\", \"toggle blame view\"),\n\t)\n\tpreview = key.NewBinding(\n\t\tkey.WithKeys(\"p\"),\n\t\tkey.WithHelp(\"p\", \"toggle preview\"),\n\t)\n)\n\n// FileItemsMsg is a message that contains a list of files.\ntype FileItemsMsg []selector.IdentifiableItem\n\n// FileContentMsg is a message that contains the content of a file.\ntype FileContentMsg struct {\n\tcontent string\n\text     string\n}\n\n// FileBlameMsg is a message that contains the blame of a file.\ntype FileBlameMsg *gitm.Blame\n\n// Files is the model for the files view.\ntype Files struct {\n\tcommon         common.Common\n\tselector       *selector.Selector\n\tref            *git.Reference\n\tactiveView     filesView\n\trepo           proto.Repository\n\tcode           *code.Code\n\tpath           string\n\tcurrentItem    *FileItem\n\tcurrentContent FileContentMsg\n\tcurrentBlame   FileBlameMsg\n\tlastSelected   []int\n\tlineNumber     bool\n\tspinner        spinner.Model\n\tcursor         int\n\tblameView      bool\n}\n\n// NewFiles creates a new files model.\nfunc NewFiles(common common.Common) *Files {\n\tf := &Files{\n\t\tcommon:       common,\n\t\tcode:         code.New(common, \"\", \"\"),\n\t\tactiveView:   filesViewLoading,\n\t\tlastSelected: make([]int, 0),\n\t\tlineNumber:   true,\n\t}\n\tselector := selector.New(common, []selector.IdentifiableItem{}, FileItemDelegate{&common})\n\tselector.SetShowFilter(false)\n\tselector.SetShowHelp(false)\n\tselector.SetShowPagination(false)\n\tselector.SetShowStatusBar(false)\n\tselector.SetShowTitle(false)\n\tselector.SetFilteringEnabled(false)\n\tselector.DisableQuitKeybindings()\n\tselector.KeyMap.NextPage = common.KeyMap.NextPage\n\tselector.KeyMap.PrevPage = common.KeyMap.PrevPage\n\tf.selector = selector\n\tf.code.ShowLineNumber = f.lineNumber\n\ts := spinner.New(spinner.WithSpinner(spinner.Dot),\n\t\tspinner.WithStyle(common.Styles.Spinner))\n\tf.spinner = s\n\treturn f\n}\n\n// Path implements common.TabComponent.\nfunc (f *Files) Path() string {\n\tpath := f.path\n\tif path == \".\" {\n\t\treturn \"\"\n\t}\n\treturn path\n}\n\n// TabName returns the tab name.\nfunc (f *Files) TabName() string {\n\treturn \"Files\"\n}\n\n// SetSize implements common.Component.\nfunc (f *Files) SetSize(width, height int) {\n\tf.common.SetSize(width, height)\n\tf.selector.SetSize(width, height)\n\tf.code.SetSize(width, height)\n}\n\n// ShortHelp implements help.KeyMap.\nfunc (f *Files) ShortHelp() []key.Binding {\n\tk := f.selector.KeyMap\n\tswitch f.activeView {\n\tcase filesViewFiles:\n\t\treturn []key.Binding{\n\t\t\tf.common.KeyMap.SelectItem,\n\t\t\tf.common.KeyMap.BackItem,\n\t\t\tk.CursorUp,\n\t\t\tk.CursorDown,\n\t\t}\n\tcase filesViewContent:\n\t\tb := []key.Binding{\n\t\t\tf.common.KeyMap.UpDown,\n\t\t\tf.common.KeyMap.BackItem,\n\t\t}\n\t\treturn b\n\tdefault:\n\t\treturn []key.Binding{}\n\t}\n}\n\n// FullHelp implements help.KeyMap.\nfunc (f *Files) FullHelp() [][]key.Binding {\n\tb := make([][]key.Binding, 0)\n\tcopyKey := f.common.KeyMap.Copy\n\tactionKeys := []key.Binding{}\n\tswitch f.activeView {\n\tcase filesViewFiles:\n\t\tcopyKey.SetHelp(\"c\", \"copy name\")\n\t\tk := f.selector.KeyMap\n\t\tb = append(b, [][]key.Binding{\n\t\t\t{\n\t\t\t\tf.common.KeyMap.SelectItem,\n\t\t\t\tf.common.KeyMap.BackItem,\n\t\t\t},\n\t\t\t{\n\t\t\t\tk.CursorUp,\n\t\t\t\tk.CursorDown,\n\t\t\t\tk.NextPage,\n\t\t\t\tk.PrevPage,\n\t\t\t},\n\t\t\t{\n\t\t\t\tk.GoToStart,\n\t\t\t\tk.GoToEnd,\n\t\t\t},\n\t\t}...)\n\tcase filesViewContent:\n\t\tif !f.code.UseGlamour {\n\t\t\tactionKeys = append(actionKeys, lineNo)\n\t\t}\n\t\tactionKeys = append(actionKeys, blameView)\n\t\tif common.IsFileMarkdown(f.currentContent.content, f.currentContent.ext) &&\n\t\t\t!f.blameView {\n\t\t\tactionKeys = append(actionKeys, preview)\n\t\t}\n\t\tcopyKey.SetHelp(\"c\", \"copy content\")\n\t\tk := f.code.KeyMap\n\t\tb = append(b, []key.Binding{\n\t\t\tf.common.KeyMap.BackItem,\n\t\t})\n\t\tb = append(b, [][]key.Binding{\n\t\t\t{\n\t\t\t\tk.PageDown,\n\t\t\t\tk.PageUp,\n\t\t\t\tk.HalfPageDown,\n\t\t\t\tk.HalfPageUp,\n\t\t\t},\n\t\t\t{\n\t\t\t\tk.Down,\n\t\t\t\tk.Up,\n\t\t\t\tf.common.KeyMap.GotoTop,\n\t\t\t\tf.common.KeyMap.GotoBottom,\n\t\t\t},\n\t\t}...)\n\t}\n\tactionKeys = append([]key.Binding{\n\t\tcopyKey,\n\t}, actionKeys...)\n\treturn append(b, actionKeys)\n}\n\n// Init implements tea.Model.\nfunc (f *Files) Init() tea.Cmd {\n\tf.path = \"\"\n\tf.currentItem = nil\n\tf.activeView = filesViewLoading\n\tf.lastSelected = make([]int, 0)\n\tf.blameView = false\n\tf.currentBlame = nil\n\tf.code.UseGlamour = false\n\treturn tea.Batch(f.spinner.Tick, f.updateFilesCmd)\n}\n\n// Update implements tea.Model.\nfunc (f *Files) Update(msg tea.Msg) (common.Model, tea.Cmd) {\n\tcmds := make([]tea.Cmd, 0)\n\tswitch msg := msg.(type) {\n\tcase RepoMsg:\n\t\tf.repo = msg\n\tcase RefMsg:\n\t\tf.ref = msg\n\t\tf.selector.Select(0)\n\t\tcmds = append(cmds, f.Init())\n\tcase FileItemsMsg:\n\t\tcmds = append(cmds,\n\t\t\tf.selector.SetItems(msg),\n\t\t)\n\t\tf.activeView = filesViewFiles\n\t\tif f.cursor >= 0 {\n\t\t\tf.selector.Select(f.cursor)\n\t\t\tf.cursor = -1\n\t\t}\n\tcase FileContentMsg:\n\t\tf.activeView = filesViewContent\n\t\tf.currentContent = msg\n\t\tf.code.UseGlamour = common.IsFileMarkdown(f.currentContent.content, f.currentContent.ext)\n\t\tcmds = append(cmds, f.code.SetContent(msg.content, msg.ext))\n\t\tf.code.GotoTop()\n\tcase FileBlameMsg:\n\t\tf.currentBlame = msg\n\t\tf.activeView = filesViewContent\n\t\tf.code.UseGlamour = false\n\t\tf.code.SetSideNote(renderBlame(f.common, f.currentItem, msg))\n\tcase selector.SelectMsg:\n\t\tswitch sel := msg.IdentifiableItem.(type) {\n\t\tcase FileItem:\n\t\t\tf.currentItem = &sel\n\t\t\tf.path = filepath.Join(f.path, sel.entry.Name())\n\t\t\tif sel.entry.IsTree() {\n\t\t\t\tcmds = append(cmds, f.selectTreeCmd)\n\t\t\t} else {\n\t\t\t\tcmds = append(cmds, f.selectFileCmd)\n\t\t\t}\n\t\t}\n\tcase GoBackMsg:\n\t\tswitch f.activeView {\n\t\tcase filesViewFiles, filesViewContent:\n\t\t\tcmds = append(cmds, f.deselectItemCmd())\n\t\t}\n\tcase tea.KeyPressMsg:\n\t\tswitch f.activeView {\n\t\tcase filesViewFiles:\n\t\t\tswitch {\n\t\t\tcase key.Matches(msg, f.common.KeyMap.SelectItem):\n\t\t\t\tcmds = append(cmds, f.selector.SelectItemCmd)\n\t\t\tcase key.Matches(msg, f.common.KeyMap.BackItem):\n\t\t\t\tcmds = append(cmds, f.deselectItemCmd())\n\t\t\t}\n\t\tcase filesViewContent:\n\t\t\tswitch {\n\t\t\tcase key.Matches(msg, f.common.KeyMap.BackItem):\n\t\t\t\tcmds = append(cmds, f.deselectItemCmd())\n\t\t\tcase key.Matches(msg, f.common.KeyMap.Copy):\n\t\t\t\tcmds = append(cmds, copyCmd(f.currentContent.content, \"File contents copied to clipboard\"))\n\t\t\tcase key.Matches(msg, lineNo) && !f.code.UseGlamour:\n\t\t\t\tf.lineNumber = !f.lineNumber\n\t\t\t\tf.code.ShowLineNumber = f.lineNumber\n\t\t\t\tcmds = append(cmds, f.code.SetContent(f.currentContent.content, f.currentContent.ext))\n\t\t\tcase key.Matches(msg, blameView):\n\t\t\t\tf.activeView = filesViewLoading\n\t\t\t\tf.blameView = !f.blameView\n\t\t\t\tif f.blameView {\n\t\t\t\t\tcmds = append(cmds, f.fetchBlame)\n\t\t\t\t} else {\n\t\t\t\t\tf.activeView = filesViewContent\n\t\t\t\t\tcmds = append(cmds, f.code.SetSideNote(\"\"))\n\t\t\t\t}\n\t\t\t\tcmds = append(cmds, f.spinner.Tick)\n\t\t\tcase key.Matches(msg, preview) &&\n\t\t\t\tcommon.IsFileMarkdown(f.currentContent.content, f.currentContent.ext) && !f.blameView:\n\t\t\t\tf.code.UseGlamour = !f.code.UseGlamour\n\t\t\t\tcmds = append(cmds, f.code.SetContent(f.currentContent.content, f.currentContent.ext))\n\t\t\t}\n\t\t}\n\tcase tea.WindowSizeMsg:\n\t\tf.SetSize(msg.Width, msg.Height)\n\t\tswitch f.activeView {\n\t\tcase filesViewFiles:\n\t\t\tif f.repo != nil {\n\t\t\t\tcmds = append(cmds, f.updateFilesCmd)\n\t\t\t}\n\t\tcase filesViewContent:\n\t\t\tif f.currentContent.content != \"\" {\n\t\t\t\tm, cmd := f.code.Update(msg)\n\t\t\t\tf.code = m.(*code.Code)\n\t\t\t\tif cmd != nil {\n\t\t\t\t\tcmds = append(cmds, cmd)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\tcase EmptyRepoMsg:\n\t\tf.ref = nil\n\t\tf.path = \"\"\n\t\tf.currentItem = nil\n\t\tf.activeView = filesViewFiles\n\t\tf.lastSelected = make([]int, 0)\n\t\tf.selector.Select(0)\n\t\tcmds = append(cmds, f.setItems([]selector.IdentifiableItem{}))\n\tcase spinner.TickMsg:\n\t\tif f.activeView == filesViewLoading && f.spinner.ID() == msg.ID {\n\t\t\ts, cmd := f.spinner.Update(msg)\n\t\t\tf.spinner = s\n\t\t\tif cmd != nil {\n\t\t\t\tcmds = append(cmds, cmd)\n\t\t\t}\n\t\t}\n\t}\n\tswitch f.activeView {\n\tcase filesViewFiles:\n\t\tm, cmd := f.selector.Update(msg)\n\t\tf.selector = m.(*selector.Selector)\n\t\tif cmd != nil {\n\t\t\tcmds = append(cmds, cmd)\n\t\t}\n\tcase filesViewContent:\n\t\tm, cmd := f.code.Update(msg)\n\t\tf.code = m.(*code.Code)\n\t\tif cmd != nil {\n\t\t\tcmds = append(cmds, cmd)\n\t\t}\n\t}\n\treturn f, tea.Batch(cmds...)\n}\n\n// View implements tea.Model.\nfunc (f *Files) View() string {\n\tswitch f.activeView {\n\tcase filesViewLoading:\n\t\treturn renderLoading(f.common, f.spinner)\n\tcase filesViewFiles:\n\t\treturn f.selector.View()\n\tcase filesViewContent:\n\t\treturn f.code.View()\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// SpinnerID implements common.TabComponent.\nfunc (f *Files) SpinnerID() int {\n\treturn f.spinner.ID()\n}\n\n// StatusBarValue returns the status bar value.\nfunc (f *Files) StatusBarValue() string {\n\tp := f.path\n\tif p == \".\" || p == \"\" {\n\t\treturn \" \"\n\t}\n\treturn p\n}\n\n// StatusBarInfo returns the status bar info.\nfunc (f *Files) StatusBarInfo() string {\n\tswitch f.activeView {\n\tcase filesViewFiles:\n\t\treturn fmt.Sprintf(\"# %d/%d\", f.selector.Index()+1, len(f.selector.VisibleItems()))\n\tcase filesViewContent:\n\t\treturn common.ScrollPercent(f.code.ScrollPosition())\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc (f *Files) updateFilesCmd() tea.Msg {\n\tfiles := make([]selector.IdentifiableItem, 0)\n\tdirs := make([]selector.IdentifiableItem, 0)\n\tif f.ref == nil {\n\t\treturn nil\n\t}\n\tr, err := f.repo.Open()\n\tif err != nil {\n\t\treturn common.ErrorCmd(err)\n\t}\n\tpath := f.path\n\tref := f.ref\n\tt, err := r.TreePath(ref, path)\n\tif err != nil {\n\t\treturn common.ErrorCmd(err)\n\t}\n\tents, err := t.Entries()\n\tif err != nil {\n\t\treturn common.ErrorCmd(err)\n\t}\n\tents.Sort()\n\tfor _, e := range ents {\n\t\tif e.IsTree() {\n\t\t\tdirs = append(dirs, FileItem{entry: e})\n\t\t} else {\n\t\t\tfiles = append(files, FileItem{entry: e})\n\t\t}\n\t}\n\treturn FileItemsMsg(append(dirs, files...))\n}\n\nfunc (f *Files) selectTreeCmd() tea.Msg {\n\tif f.currentItem != nil && f.currentItem.entry.IsTree() {\n\t\tf.lastSelected = append(f.lastSelected, f.selector.Index())\n\t\tf.cursor = 0\n\t\treturn f.updateFilesCmd()\n\t}\n\treturn common.ErrorMsg(errNoFileSelected)\n}\n\nfunc (f *Files) selectFileCmd() tea.Msg {\n\ti := f.currentItem\n\tif i != nil && !i.entry.IsTree() {\n\t\tfi := i.entry.File()\n\t\tif i.Mode().IsDir() || f == nil {\n\t\t\treturn common.ErrorMsg(errInvalidFile)\n\t\t}\n\n\t\tvar err error\n\t\tvar bin bool\n\n\t\tr, err := f.repo.Open()\n\t\tif err == nil {\n\t\t\tattrs, err := r.CheckAttributes(f.ref, fi.Path())\n\t\t\tif err == nil {\n\t\t\t\tfor _, attr := range attrs {\n\t\t\t\t\tif (attr.Name == \"binary\" && attr.Value == \"set\") ||\n\t\t\t\t\t\t(attr.Name == \"text\" && attr.Value == \"unset\") {\n\t\t\t\t\t\tbin = true\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif !bin {\n\t\t\tbin, err = fi.IsBinary()\n\t\t\tif err != nil {\n\t\t\t\tf.path = filepath.Dir(f.path)\n\t\t\t\treturn common.ErrorMsg(err)\n\t\t\t}\n\t\t}\n\n\t\tif bin {\n\t\t\tf.path = filepath.Dir(f.path)\n\t\t\treturn common.ErrorMsg(errBinaryFile)\n\t\t}\n\n\t\tc, err := fi.Bytes()\n\t\tif err != nil {\n\t\t\tf.path = filepath.Dir(f.path)\n\t\t\treturn common.ErrorMsg(err)\n\t\t}\n\n\t\tf.lastSelected = append(f.lastSelected, f.selector.Index())\n\t\treturn FileContentMsg{string(c), i.entry.Name()}\n\t}\n\n\treturn common.ErrorMsg(errNoFileSelected)\n}\n\nfunc (f *Files) fetchBlame() tea.Msg {\n\tr, err := f.repo.Open()\n\tif err != nil {\n\t\treturn common.ErrorMsg(err)\n\t}\n\n\tb, err := r.BlameFile(f.ref.ID, f.currentItem.entry.File().Path())\n\tif err != nil {\n\t\treturn common.ErrorMsg(err)\n\t}\n\n\treturn FileBlameMsg(b)\n}\n\nfunc renderBlame(c common.Common, f *FileItem, b *gitm.Blame) string {\n\tif f == nil || f.entry.IsTree() || b == nil {\n\t\treturn \"\"\n\t}\n\n\tlines := make([]string, 0)\n\ti := 1\n\tvar prev string\n\tfor {\n\t\tcommit := b.Line(i)\n\t\tif commit == nil {\n\t\t\tbreak\n\t\t}\n\t\twho := fmt.Sprintf(\"%s <%s>\", commit.Author.Name, commit.Author.Email)\n\t\tline := fmt.Sprintf(\"%s %s %s\",\n\t\t\tc.Styles.Tree.Blame.Hash.Render(commit.ID.String()[:7]),\n\t\t\tc.Styles.Tree.Blame.Message.Render(commit.Summary()),\n\t\t\tc.Styles.Tree.Blame.Who.Render(who),\n\t\t)\n\t\tif line != prev {\n\t\t\tlines = append(lines, line)\n\t\t} else {\n\t\t\tlines = append(lines, \"\")\n\t\t}\n\t\tprev = line\n\t\ti++\n\t}\n\n\treturn strings.Join(lines, \"\\n\")\n}\n\nfunc (f *Files) deselectItemCmd() tea.Cmd {\n\tf.path = filepath.Dir(f.path)\n\tindex := 0\n\tif len(f.lastSelected) > 0 {\n\t\tindex = f.lastSelected[len(f.lastSelected)-1]\n\t\tf.lastSelected = f.lastSelected[:len(f.lastSelected)-1]\n\t}\n\tf.cursor = index\n\tf.activeView = filesViewFiles\n\tf.code.SetSideNote(\"\")\n\tf.blameView = false\n\tf.currentBlame = nil\n\tf.code.UseGlamour = false\n\treturn f.updateFilesCmd\n}\n\nfunc (f *Files) setItems(items []selector.IdentifiableItem) tea.Cmd {\n\treturn func() tea.Msg {\n\t\treturn FileItemsMsg(items)\n\t}\n}\n"
  },
  {
    "path": "pkg/ui/pages/repo/filesitem.go",
    "content": "package repo\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"strings\"\n\n\t\"charm.land/bubbles/v2/key\"\n\t\"charm.land/bubbles/v2/list\"\n\ttea \"charm.land/bubbletea/v2\"\n\t\"charm.land/lipgloss/v2\"\n\t\"github.com/charmbracelet/soft-serve/git\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/common\"\n\t\"github.com/dustin/go-humanize\"\n)\n\n// FileItem is a list item for a file.\ntype FileItem struct {\n\tentry *git.TreeEntry\n}\n\n// ID returns the ID of the file item.\nfunc (i FileItem) ID() string {\n\treturn i.entry.Name()\n}\n\n// Title returns the title of the file item.\nfunc (i FileItem) Title() string {\n\treturn common.UnquoteFilename(i.entry.Name())\n}\n\n// Description returns the description of the file item.\nfunc (i FileItem) Description() string {\n\treturn \"\"\n}\n\n// Mode returns the mode of the file item.\nfunc (i FileItem) Mode() fs.FileMode {\n\treturn i.entry.Mode()\n}\n\n// FilterValue implements list.Item.\nfunc (i FileItem) FilterValue() string { return i.Title() }\n\n// FileItems is a list of file items.\ntype FileItems []FileItem\n\n// Len implements sort.Interface.\nfunc (cl FileItems) Len() int { return len(cl) }\n\n// Swap implements sort.Interface.\nfunc (cl FileItems) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }\n\n// Less implements sort.Interface.\nfunc (cl FileItems) Less(i, j int) bool {\n\tif cl[i].entry.IsTree() && cl[j].entry.IsTree() {\n\t\treturn cl[i].Title() < cl[j].Title()\n\t} else if cl[i].entry.IsTree() {\n\t\treturn true\n\t} else if cl[j].entry.IsTree() {\n\t\treturn false\n\t}\n\treturn cl[i].Title() < cl[j].Title()\n}\n\n// FileItemDelegate is the delegate for the file item list.\ntype FileItemDelegate struct {\n\tcommon *common.Common\n}\n\n// Height returns the height of the file item list. Implements list.ItemDelegate.\nfunc (d FileItemDelegate) Height() int { return 1 }\n\n// Spacing returns the spacing of the file item list. Implements list.ItemDelegate.\nfunc (d FileItemDelegate) Spacing() int { return 0 }\n\n// Update implements list.ItemDelegate.\nfunc (d FileItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {\n\titem, ok := m.SelectedItem().(FileItem)\n\tif !ok {\n\t\treturn nil\n\t}\n\tswitch msg := msg.(type) {\n\tcase tea.KeyPressMsg:\n\t\tswitch {\n\t\tcase key.Matches(msg, d.common.KeyMap.Copy):\n\t\t\treturn copyCmd(item.entry.Name(), fmt.Sprintf(\"File name %q copied to clipboard\", item.entry.Name()))\n\t\t}\n\t}\n\treturn nil\n}\n\n// Render implements list.ItemDelegate.\nfunc (d FileItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {\n\ti, ok := listItem.(FileItem)\n\tif !ok {\n\t\treturn\n\t}\n\n\ts := d.common.Styles.Tree\n\n\tname := i.Title()\n\tsize := humanize.Bytes(uint64(i.entry.Size())) //nolint:gosec\n\tsize = strings.ReplaceAll(size, \" \", \"\")\n\tsizeLen := lipgloss.Width(size)\n\tif i.entry.IsTree() {\n\t\tsize = strings.Repeat(\" \", sizeLen)\n\t\tif index == m.Index() {\n\t\t\tname = s.Active.FileDir.Render(name)\n\t\t} else {\n\t\t\tname = s.Normal.FileDir.Render(name)\n\t\t}\n\t}\n\tvar nameStyle, sizeStyle, modeStyle lipgloss.Style\n\tmode := i.Mode()\n\tif index == m.Index() {\n\t\tnameStyle = s.Active.FileName\n\t\tsizeStyle = s.Active.FileSize\n\t\tmodeStyle = s.Active.FileMode\n\t\tfmt.Fprint(w, s.Selector.Render(\">\")) //nolint:errcheck\n\t} else {\n\t\tnameStyle = s.Normal.FileName\n\t\tsizeStyle = s.Normal.FileSize\n\t\tmodeStyle = s.Normal.FileMode\n\t\tfmt.Fprint(w, s.Selector.Render(\" \")) //nolint:errcheck\n\t}\n\tsizeStyle = sizeStyle.\n\t\tWidth(8).\n\t\tAlign(lipgloss.Right).\n\t\tMarginLeft(1)\n\tleftMargin := s.Selector.GetMarginLeft() +\n\t\ts.Selector.GetWidth() +\n\t\ts.Normal.FileMode.GetMarginLeft() +\n\t\ts.Normal.FileMode.GetWidth() +\n\t\tnameStyle.GetMarginLeft() +\n\t\tsizeStyle.GetHorizontalFrameSize()\n\tname = common.TruncateString(name, m.Width()-leftMargin)\n\tname = nameStyle.Render(name)\n\tsize = sizeStyle.Render(size)\n\tmodeStr := modeStyle.Render(mode.String())\n\ttruncate := lipgloss.NewStyle().MaxWidth(m.Width() -\n\t\ts.Selector.GetHorizontalFrameSize() -\n\t\ts.Selector.GetWidth())\n\t//nolint:errcheck\n\tfmt.Fprint(w,\n\t\td.common.Zone.Mark(\n\t\t\ti.ID(),\n\t\t\ttruncate.Render(fmt.Sprintf(\"%s%s%s\",\n\t\t\t\tmodeStr,\n\t\t\t\tsize,\n\t\t\t\tname,\n\t\t\t)),\n\t\t),\n\t)\n}\n"
  },
  {
    "path": "pkg/ui/pages/repo/log.go",
    "content": "package repo\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"charm.land/bubbles/v2/key\"\n\t\"charm.land/bubbles/v2/spinner\"\n\ttea \"charm.land/bubbletea/v2\"\n\tgansi \"charm.land/glamour/v2/ansi\"\n\t\"charm.land/lipgloss/v2\"\n\t\"github.com/charmbracelet/soft-serve/git\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/common\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/components/footer\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/components/selector\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/components/viewport\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/styles\"\n\t\"github.com/muesli/reflow/wrap\"\n)\n\nvar waitBeforeLoading = time.Millisecond * 100\n\ntype logView int\n\nconst (\n\tlogViewLoading logView = iota\n\tlogViewCommits\n\tlogViewDiff\n)\n\n// LogCountMsg is a message that contains the number of commits in a repo.\ntype LogCountMsg int64\n\n// LogItemsMsg is a message that contains a slice of LogItem.\ntype LogItemsMsg []selector.IdentifiableItem\n\n// LogCommitMsg is a message that contains a git commit.\ntype LogCommitMsg *git.Commit\n\n// LogDiffMsg is a message that contains a git diff.\ntype LogDiffMsg *git.Diff\n\n// Log is a model that displays a list of commits and their diffs.\ntype Log struct {\n\tcommon         common.Common\n\tselector       *selector.Selector\n\tvp             *viewport.Viewport\n\tactiveView     logView\n\trepo           proto.Repository\n\tref            *git.Reference\n\tcount          int64\n\tnextPage       int\n\tactiveCommit   *git.Commit\n\tselectedCommit *git.Commit\n\tcurrentDiff    *git.Diff\n\tloadingTime    time.Time\n\tspinner        spinner.Model\n}\n\n// NewLog creates a new Log model.\nfunc NewLog(common common.Common) *Log {\n\tl := &Log{\n\t\tcommon:     common,\n\t\tvp:         viewport.New(common),\n\t\tactiveView: logViewCommits,\n\t}\n\tselector := selector.New(common, []selector.IdentifiableItem{}, LogItemDelegate{&common})\n\tselector.SetShowFilter(false)\n\tselector.SetShowHelp(false)\n\tselector.SetShowPagination(false)\n\tselector.SetShowStatusBar(false)\n\tselector.SetShowTitle(false)\n\tselector.SetFilteringEnabled(false)\n\tselector.DisableQuitKeybindings()\n\tselector.KeyMap.NextPage = common.KeyMap.NextPage\n\tselector.KeyMap.PrevPage = common.KeyMap.PrevPage\n\tl.selector = selector\n\ts := spinner.New(spinner.WithSpinner(spinner.Dot),\n\t\tspinner.WithStyle(common.Styles.Spinner))\n\tl.spinner = s\n\treturn l\n}\n\n// Path implements common.TabComponent.\nfunc (l *Log) Path() string {\n\tswitch l.activeView {\n\tcase logViewCommits:\n\t\treturn \"\"\n\tdefault:\n\t\treturn \"diff\" // XXX: this is a place holder and doesn't mean anything\n\t}\n}\n\n// TabName returns the name of the tab.\nfunc (l *Log) TabName() string {\n\treturn \"Commits\"\n}\n\n// SetSize implements common.Component.\nfunc (l *Log) SetSize(width, height int) {\n\tl.common.SetSize(width, height)\n\tl.selector.SetSize(width, height)\n\tl.vp.SetSize(width, height)\n}\n\n// ShortHelp implements help.KeyMap.\nfunc (l *Log) ShortHelp() []key.Binding {\n\tswitch l.activeView {\n\tcase logViewCommits:\n\t\tcopyKey := l.common.KeyMap.Copy\n\t\tcopyKey.SetHelp(\"c\", \"copy hash\")\n\t\treturn []key.Binding{\n\t\t\tl.common.KeyMap.UpDown,\n\t\t\tl.common.KeyMap.SelectItem,\n\t\t\tcopyKey,\n\t\t}\n\tcase logViewDiff:\n\t\tcopyKey := l.common.KeyMap.Copy\n\t\tcopyKey.SetHelp(\"c\", \"copy diff\")\n\t\treturn []key.Binding{\n\t\t\tl.common.KeyMap.UpDown,\n\t\t\tl.common.KeyMap.BackItem,\n\t\t\tcopyKey,\n\t\t\tl.common.KeyMap.GotoTop,\n\t\t\tl.common.KeyMap.GotoBottom,\n\t\t}\n\tdefault:\n\t\treturn []key.Binding{}\n\t}\n}\n\n// FullHelp implements help.KeyMap.\nfunc (l *Log) FullHelp() [][]key.Binding {\n\tk := l.selector.KeyMap\n\tb := make([][]key.Binding, 0)\n\tswitch l.activeView {\n\tcase logViewCommits:\n\t\tcopyKey := l.common.KeyMap.Copy\n\t\tcopyKey.SetHelp(\"c\", \"copy hash\")\n\t\tb = append(b, []key.Binding{\n\t\t\tl.common.KeyMap.SelectItem,\n\t\t\tl.common.KeyMap.BackItem,\n\t\t})\n\t\tb = append(b, [][]key.Binding{\n\t\t\t{\n\t\t\t\tcopyKey,\n\t\t\t\tk.CursorUp,\n\t\t\t\tk.CursorDown,\n\t\t\t},\n\t\t\t{\n\t\t\t\tk.NextPage,\n\t\t\t\tk.PrevPage,\n\t\t\t\tk.GoToStart,\n\t\t\t\tk.GoToEnd,\n\t\t\t},\n\t\t}...)\n\tcase logViewDiff:\n\t\tcopyKey := l.common.KeyMap.Copy\n\t\tcopyKey.SetHelp(\"c\", \"copy diff\")\n\t\tk := l.vp.KeyMap\n\t\tb = append(b, []key.Binding{\n\t\t\tl.common.KeyMap.BackItem,\n\t\t\tcopyKey,\n\t\t})\n\t\tb = append(b, [][]key.Binding{\n\t\t\t{\n\t\t\t\tk.PageDown,\n\t\t\t\tk.PageUp,\n\t\t\t\tk.HalfPageDown,\n\t\t\t\tk.HalfPageUp,\n\t\t\t},\n\t\t\t{\n\t\t\t\tk.Down,\n\t\t\t\tk.Up,\n\t\t\t\tl.common.KeyMap.GotoTop,\n\t\t\t\tl.common.KeyMap.GotoBottom,\n\t\t\t},\n\t\t}...)\n\t}\n\treturn b\n}\n\nfunc (l *Log) startLoading() tea.Cmd {\n\tl.loadingTime = time.Now()\n\tl.activeView = logViewLoading\n\treturn l.spinner.Tick\n}\n\n// Init implements tea.Model.\nfunc (l *Log) Init() tea.Cmd {\n\tl.activeView = logViewCommits\n\tl.nextPage = 0\n\tl.count = 0\n\tl.activeCommit = nil\n\tl.selectedCommit = nil\n\treturn tea.Batch(\n\t\tl.countCommitsCmd,\n\t\t// start loading on init\n\t\tl.startLoading(),\n\t)\n}\n\n// Update implements tea.Model.\nfunc (l *Log) Update(msg tea.Msg) (common.Model, tea.Cmd) {\n\tcmds := make([]tea.Cmd, 0)\n\tswitch msg := msg.(type) {\n\tcase RepoMsg:\n\t\tl.repo = msg\n\tcase RefMsg:\n\t\tl.ref = msg\n\t\tl.selector.Select(0)\n\t\tcmds = append(cmds, l.Init())\n\tcase LogCountMsg:\n\t\tl.count = int64(msg)\n\t\tl.selector.SetTotalPages(int(msg))\n\t\tl.selector.SetItems(make([]selector.IdentifiableItem, l.count))\n\t\tcmds = append(cmds, l.updateCommitsCmd)\n\tcase LogItemsMsg:\n\t\t// stop loading after receiving items\n\t\tl.activeView = logViewCommits\n\t\tcmds = append(cmds, l.selector.SetItems(msg))\n\t\tl.selector.SetPage(l.nextPage)\n\t\tl.SetSize(l.common.Width, l.common.Height)\n\t\ti := l.selector.SelectedItem()\n\t\tif i != nil {\n\t\t\tl.activeCommit = i.(LogItem).Commit\n\t\t}\n\tcase tea.KeyPressMsg, tea.MouseClickMsg:\n\t\tswitch l.activeView {\n\t\tcase logViewCommits:\n\t\t\tswitch kmsg := msg.(type) {\n\t\t\tcase tea.KeyPressMsg:\n\t\t\t\tswitch {\n\t\t\t\tcase key.Matches(kmsg, l.common.KeyMap.SelectItem):\n\t\t\t\t\tcmds = append(cmds, l.selector.SelectItemCmd)\n\t\t\t\t}\n\t\t\t}\n\t\t\t// XXX: This is a hack for loading commits on demand based on\n\t\t\t// list.Pagination.\n\t\t\tcurPage := l.selector.Page()\n\t\t\ts, cmd := l.selector.Update(msg)\n\t\t\tm := s.(*selector.Selector)\n\t\t\tl.selector = m\n\t\t\tif m.Page() != curPage {\n\t\t\t\tl.nextPage = m.Page()\n\t\t\t\tl.selector.SetPage(curPage)\n\t\t\t\tcmds = append(cmds,\n\t\t\t\t\tl.updateCommitsCmd,\n\t\t\t\t\tl.startLoading(),\n\t\t\t\t)\n\t\t\t}\n\t\t\tcmds = append(cmds, cmd)\n\t\tcase logViewDiff:\n\t\t\tswitch kmsg := msg.(type) {\n\t\t\tcase tea.KeyPressMsg:\n\t\t\t\tswitch {\n\t\t\t\tcase key.Matches(kmsg, l.common.KeyMap.BackItem):\n\t\t\t\t\tl.goBack()\n\t\t\t\tcase key.Matches(kmsg, l.common.KeyMap.Copy):\n\t\t\t\t\tif l.currentDiff != nil {\n\t\t\t\t\t\tcmds = append(cmds, copyCmd(l.currentDiff.Patch(), \"Commit diff copied to clipboard\"))\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\tcase GoBackMsg:\n\t\tl.goBack()\n\tcase selector.ActiveMsg:\n\t\tswitch sel := msg.IdentifiableItem.(type) {\n\t\tcase LogItem:\n\t\t\tl.activeCommit = sel.Commit\n\t\t}\n\tcase selector.SelectMsg:\n\t\tswitch sel := msg.IdentifiableItem.(type) {\n\t\tcase LogItem:\n\t\t\tcmds = append(cmds,\n\t\t\t\tl.selectCommitCmd(sel.Commit),\n\t\t\t\tl.startLoading(),\n\t\t\t)\n\t\t}\n\tcase LogCommitMsg:\n\t\tl.selectedCommit = msg\n\t\tcmds = append(cmds, l.loadDiffCmd)\n\tcase LogDiffMsg:\n\t\tl.currentDiff = msg\n\t\tl.vp.SetContent(\n\t\t\tlipgloss.JoinVertical(lipgloss.Left,\n\t\t\t\tl.renderCommit(l.selectedCommit),\n\t\t\t\trenderSummary(msg, l.common.Styles, l.common.Width),\n\t\t\t\trenderDiff(msg, l.common.Width),\n\t\t\t),\n\t\t)\n\t\tl.vp.GotoTop()\n\t\tl.activeView = logViewDiff\n\tcase footer.ToggleFooterMsg:\n\t\tcmds = append(cmds, l.updateCommitsCmd)\n\tcase tea.WindowSizeMsg:\n\t\tl.SetSize(msg.Width, msg.Height)\n\t\tif l.selectedCommit != nil && l.currentDiff != nil {\n\t\t\tl.vp.SetContent(\n\t\t\t\tlipgloss.JoinVertical(lipgloss.Left,\n\t\t\t\t\tl.renderCommit(l.selectedCommit),\n\t\t\t\t\trenderSummary(l.currentDiff, l.common.Styles, l.common.Width),\n\t\t\t\t\trenderDiff(l.currentDiff, l.common.Width),\n\t\t\t\t),\n\t\t\t)\n\t\t}\n\t\tif l.repo != nil && l.ref != nil {\n\t\t\tcmds = append(cmds,\n\t\t\t\tl.updateCommitsCmd,\n\t\t\t\t// start loading on resize since the number of commits per page\n\t\t\t\t// might change and we'd need to load more commits.\n\t\t\t\tl.startLoading(),\n\t\t\t)\n\t\t}\n\tcase EmptyRepoMsg:\n\t\tl.ref = nil\n\t\tl.activeView = logViewCommits\n\t\tl.nextPage = 0\n\t\tl.count = 0\n\t\tl.activeCommit = nil\n\t\tl.selectedCommit = nil\n\t\tl.selector.Select(0)\n\t\tcmds = append(cmds,\n\t\t\tl.setItems([]selector.IdentifiableItem{}),\n\t\t)\n\tcase spinner.TickMsg:\n\t\tif l.activeView == logViewLoading && l.spinner.ID() == msg.ID {\n\t\t\ts, cmd := l.spinner.Update(msg)\n\t\t\tif cmd != nil {\n\t\t\t\tcmds = append(cmds, cmd)\n\t\t\t}\n\t\t\tl.spinner = s\n\t\t}\n\t}\n\tswitch l.activeView {\n\tcase logViewDiff:\n\t\tvp, cmd := l.vp.Update(msg)\n\t\tl.vp = vp.(*viewport.Viewport)\n\t\tif cmd != nil {\n\t\t\tcmds = append(cmds, cmd)\n\t\t}\n\t}\n\treturn l, tea.Batch(cmds...)\n}\n\n// View implements tea.Model.\nfunc (l *Log) View() string {\n\tswitch l.activeView {\n\tcase logViewLoading:\n\t\tif l.loadingTime.Add(waitBeforeLoading).Before(time.Now()) {\n\t\t\tmsg := fmt.Sprintf(\"%s loading commit\", l.spinner.View())\n\t\t\tif l.selectedCommit == nil {\n\t\t\t\tmsg += \"s\"\n\t\t\t}\n\t\t\tmsg += \"…\"\n\t\t\treturn l.common.Styles.SpinnerContainer.\n\t\t\t\tHeight(l.common.Height).\n\t\t\t\tRender(msg)\n\t\t}\n\t\tfallthrough\n\tcase logViewCommits:\n\t\treturn l.selector.View()\n\tcase logViewDiff:\n\t\treturn l.vp.View()\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// SpinnerID implements common.TabComponent.\nfunc (l *Log) SpinnerID() int {\n\treturn l.spinner.ID()\n}\n\n// StatusBarValue returns the status bar value.\nfunc (l *Log) StatusBarValue() string {\n\tif l.activeView == logViewLoading {\n\t\treturn \"\"\n\t}\n\tc := l.activeCommit\n\tif c == nil {\n\t\treturn \"\"\n\t}\n\twho := c.Author.Name\n\tif email := c.Author.Email; email != \"\" {\n\t\twho += \" <\" + email + \">\"\n\t}\n\tvalue := c.ID.String()[:7]\n\tif who != \"\" {\n\t\tvalue += \" by \" + who\n\t}\n\treturn value\n}\n\n// StatusBarInfo returns the status bar info.\nfunc (l *Log) StatusBarInfo() string {\n\tswitch l.activeView {\n\tcase logViewLoading:\n\t\tif l.count == 0 {\n\t\t\treturn \"\"\n\t\t}\n\t\tfallthrough\n\tcase logViewCommits:\n\t\t// We're using l.nextPage instead of l.selector.Paginator.Page because\n\t\t// of the paginator hack above.\n\t\treturn fmt.Sprintf(\"p. %d/%d\", l.nextPage+1, l.selector.TotalPages())\n\tcase logViewDiff:\n\t\treturn fmt.Sprintf(\"☰ %.f%%\", l.vp.ScrollPercent()*100)\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\nfunc (l *Log) goBack() {\n\tif l.activeView == logViewDiff {\n\t\tl.activeView = logViewCommits\n\t\tl.selectedCommit = nil\n\t}\n}\n\nfunc (l *Log) countCommitsCmd() tea.Msg {\n\tif l.ref == nil {\n\t\treturn nil\n\t}\n\tr, err := l.repo.Open()\n\tif err != nil {\n\t\treturn common.ErrorMsg(err)\n\t}\n\tcount, err := r.CountCommits(l.ref)\n\tif err != nil {\n\t\tl.common.Logger.Debugf(\"ui: error counting commits: %v\", err)\n\t\treturn common.ErrorMsg(err)\n\t}\n\treturn LogCountMsg(count)\n}\n\nfunc (l *Log) updateCommitsCmd() tea.Msg {\n\tif l.ref == nil {\n\t\treturn nil\n\t}\n\tr, err := l.repo.Open()\n\tif err != nil {\n\t\treturn common.ErrorMsg(err)\n\t}\n\n\tcount := l.count\n\tif count == 0 {\n\t\treturn LogItemsMsg([]selector.IdentifiableItem{})\n\t}\n\n\tpage := l.nextPage\n\tlimit := l.selector.PerPage()\n\tskip := page * limit\n\tref := l.ref\n\titems := make([]selector.IdentifiableItem, count)\n\t// CommitsByPage pages start at 1\n\tcc, err := r.CommitsByPage(ref, page+1, limit)\n\tif err != nil {\n\t\tl.common.Logger.Debugf(\"ui: error loading commits: %v\", err)\n\t\treturn common.ErrorMsg(err)\n\t}\n\tfor i, c := range cc {\n\t\tidx := i + skip\n\t\tif int64(idx) >= count {\n\t\t\tbreak\n\t\t}\n\t\titems[idx] = LogItem{Commit: c}\n\t}\n\treturn LogItemsMsg(items)\n}\n\nfunc (l *Log) selectCommitCmd(commit *git.Commit) tea.Cmd {\n\treturn func() tea.Msg {\n\t\treturn LogCommitMsg(commit)\n\t}\n}\n\nfunc (l *Log) loadDiffCmd() tea.Msg {\n\tif l.selectedCommit == nil {\n\t\treturn nil\n\t}\n\tr, err := l.repo.Open()\n\tif err != nil {\n\t\tl.common.Logger.Debugf(\"ui: error loading diff repository: %v\", err)\n\t\treturn common.ErrorMsg(err)\n\t}\n\tdiff, err := r.Diff(l.selectedCommit)\n\tif err != nil {\n\t\tl.common.Logger.Debugf(\"ui: error loading diff: %v\", err)\n\t\treturn common.ErrorMsg(err)\n\t}\n\treturn LogDiffMsg(diff)\n}\n\nfunc (l *Log) renderCommit(c *git.Commit) string {\n\ts := strings.Builder{}\n\t// FIXME: lipgloss prints empty lines when CRLF is used\n\t// sanitize commit message from CRLF\n\tmsg := strings.ReplaceAll(c.Message, \"\\r\\n\", \"\\n\")\n\ts.WriteString(fmt.Sprintf(\"%s\\n%s\\n%s\\n%s\\n\",\n\t\tl.common.Styles.Log.CommitHash.Render(\"commit \"+c.ID.String()),\n\t\tl.common.Styles.Log.CommitAuthor.Render(fmt.Sprintf(\"Author: %s <%s>\", c.Author.Name, c.Author.Email)),\n\t\tl.common.Styles.Log.CommitDate.Render(\"Date:   \"+c.Committer.When.Format(time.UnixDate)),\n\t\tl.common.Styles.Log.CommitBody.Render(msg),\n\t))\n\treturn wrap.String(s.String(), l.common.Width-2)\n}\n\nfunc renderSummary(diff *git.Diff, styles *styles.Styles, width int) string {\n\tstats := strings.Split(diff.Stats().String(), \"\\n\")\n\tfor i, line := range stats {\n\t\tch := strings.Split(line, \"|\")\n\t\tif len(ch) > 1 {\n\t\t\tadddel := ch[len(ch)-1]\n\t\t\tadddel = strings.ReplaceAll(adddel, \"+\", styles.Log.CommitStatsAdd.Render(\"+\"))\n\t\t\tadddel = strings.ReplaceAll(adddel, \"-\", styles.Log.CommitStatsDel.Render(\"-\"))\n\t\t\tstats[i] = strings.Join(ch[:len(ch)-1], \"|\") + \"|\" + adddel\n\t\t}\n\t}\n\treturn wrap.String(strings.Join(stats, \"\\n\"), width-2)\n}\n\nfunc renderDiff(diff *git.Diff, width int) string {\n\tvar s strings.Builder\n\tvar pr strings.Builder\n\tdiffChroma := &gansi.CodeBlockElement{\n\t\tCode:     diff.Patch(),\n\t\tLanguage: \"diff\",\n\t}\n\terr := diffChroma.Render(&pr, common.StyleRenderer())\n\tif err != nil {\n\t\ts.WriteString(fmt.Sprintf(\"\\n%s\", err.Error()))\n\t} else {\n\t\ts.WriteString(fmt.Sprintf(\"\\n%s\", pr.String()))\n\t}\n\treturn wrap.String(s.String(), width)\n}\n\nfunc (l *Log) setItems(items []selector.IdentifiableItem) tea.Cmd {\n\treturn func() tea.Msg {\n\t\treturn LogItemsMsg(items)\n\t}\n}\n"
  },
  {
    "path": "pkg/ui/pages/repo/logitem.go",
    "content": "package repo\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"strings\"\n\t\"time\"\n\n\t\"charm.land/bubbles/v2/key\"\n\t\"charm.land/bubbles/v2/list\"\n\ttea \"charm.land/bubbletea/v2\"\n\t\"charm.land/lipgloss/v2\"\n\t\"github.com/charmbracelet/soft-serve/git\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/common\"\n\t\"github.com/muesli/reflow/truncate\"\n)\n\n// LogItem is a item in the log list that displays a git commit.\ntype LogItem struct {\n\t*git.Commit\n}\n\n// ID implements selector.IdentifiableItem.\nfunc (i LogItem) ID() string {\n\treturn i.Hash()\n}\n\n// Hash returns the commit hash.\nfunc (i LogItem) Hash() string {\n\treturn i.Commit.ID.String()\n}\n\n// Title returns the item title. Implements list.DefaultItem.\nfunc (i LogItem) Title() string {\n\tif i.Commit != nil {\n\t\treturn strings.Split(i.Commit.Message, \"\\n\")[0]\n\t}\n\treturn \"\"\n}\n\n// Description returns the item description. Implements list.DefaultItem.\nfunc (i LogItem) Description() string { return \"\" }\n\n// FilterValue implements list.Item.\nfunc (i LogItem) FilterValue() string { return i.Title() }\n\n// LogItemDelegate is the delegate for LogItem.\ntype LogItemDelegate struct {\n\tcommon *common.Common\n}\n\n// Height returns the item height. Implements list.ItemDelegate.\nfunc (d LogItemDelegate) Height() int { return 2 }\n\n// Spacing returns the item spacing. Implements list.ItemDelegate.\nfunc (d LogItemDelegate) Spacing() int { return 1 }\n\n// Update updates the item. Implements list.ItemDelegate.\nfunc (d LogItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {\n\titem, ok := m.SelectedItem().(LogItem)\n\tif !ok {\n\t\treturn nil\n\t}\n\tswitch msg := msg.(type) {\n\tcase tea.KeyPressMsg:\n\t\tswitch {\n\t\tcase key.Matches(msg, d.common.KeyMap.Copy):\n\t\t\treturn copyCmd(item.Hash(), \"Commit hash copied to clipboard\")\n\t\t}\n\t}\n\treturn nil\n}\n\n// Render renders the item. Implements list.ItemDelegate.\nfunc (d LogItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {\n\ti, ok := listItem.(LogItem)\n\tif !ok {\n\t\treturn\n\t}\n\tif i.Commit == nil {\n\t\treturn\n\t}\n\n\tstyles := d.common.Styles.LogItem.Normal\n\tif index == m.Index() {\n\t\tstyles = d.common.Styles.LogItem.Active\n\t}\n\n\thorizontalFrameSize := styles.Base.GetHorizontalFrameSize()\n\n\thash := i.Commit.ID.String()[:7]\n\ttitle := styles.Title.Render(\n\t\tcommon.TruncateString(i.Title(),\n\t\t\tm.Width()-\n\t\t\t\thorizontalFrameSize-\n\t\t\t\t// 9 is the length of the hash (7) + the left padding (1) + the\n\t\t\t\t// title truncation symbol (1)\n\t\t\t\t9),\n\t)\n\thashStyle := styles.Hash.\n\t\tAlign(lipgloss.Right).\n\t\tPaddingLeft(1).\n\t\tWidth(m.Width() -\n\t\t\thorizontalFrameSize -\n\t\t\tlipgloss.Width(title) - 1) // 1 is for the left padding\n\tif index == m.Index() {\n\t\thashStyle = hashStyle.Bold(true)\n\t}\n\thash = hashStyle.Render(hash)\n\tif m.Width()-horizontalFrameSize-hashStyle.GetHorizontalFrameSize()-hashStyle.GetWidth() <= 0 {\n\t\thash = \"\"\n\t\ttitle = styles.Title.Render(\n\t\t\tcommon.TruncateString(i.Title(),\n\t\t\t\tm.Width()-horizontalFrameSize),\n\t\t)\n\t}\n\tauthor := i.Author.Name\n\tcommitter := i.Committer.Name\n\twho := \"\"\n\tif author != \"\" && committer != \"\" {\n\t\twho = styles.Keyword.Render(committer) + styles.Desc.Render(\" committed\")\n\t\tif author != committer {\n\t\t\twho = styles.Keyword.Render(author) + styles.Desc.Render(\" authored and \") + who\n\t\t}\n\t\twho += \" \"\n\t}\n\tdate := i.Committer.When.Format(\"Jan 02\")\n\tif i.Committer.When.Year() != time.Now().Year() {\n\t\tdate += fmt.Sprintf(\" %d\", i.Committer.When.Year())\n\t}\n\twho += styles.Desc.Render(\"on \") + styles.Keyword.Render(date)\n\twho = common.TruncateString(who, m.Width()-horizontalFrameSize)\n\tfmt.Fprint(w, //nolint:errcheck\n\t\td.common.Zone.Mark(\n\t\t\ti.ID(),\n\t\t\tstyles.Base.Render(\n\t\t\t\tlipgloss.JoinVertical(lipgloss.Left,\n\t\t\t\t\ttruncate.String(fmt.Sprintf(\"%s%s\",\n\t\t\t\t\t\ttitle,\n\t\t\t\t\t\thash,\n\t\t\t\t\t), uint(m.Width()-horizontalFrameSize)), //nolint:gosec\n\t\t\t\t\twho,\n\t\t\t\t),\n\t\t\t),\n\t\t),\n\t)\n}\n"
  },
  {
    "path": "pkg/ui/pages/repo/readme.go",
    "content": "package repo\n\nimport (\n\t\"path/filepath\"\n\n\t\"charm.land/bubbles/v2/key\"\n\t\"charm.land/bubbles/v2/spinner\"\n\ttea \"charm.land/bubbletea/v2\"\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/common\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/components/code\"\n)\n\n// ReadmeMsg is a message sent when the readme is loaded.\ntype ReadmeMsg struct {\n\tContent string\n\tPath    string\n}\n\n// Readme is the readme component page.\ntype Readme struct {\n\tcommon     common.Common\n\tcode       *code.Code\n\tref        RefMsg\n\trepo       proto.Repository\n\treadmePath string\n\tspinner    spinner.Model\n\tisLoading  bool\n}\n\n// NewReadme creates a new readme model.\nfunc NewReadme(common common.Common) *Readme {\n\treadme := code.New(common, \"\", \"\")\n\treadme.NoContentStyle = readme.NoContentStyle.SetString(\"No readme found.\")\n\treadme.UseGlamour = true\n\ts := spinner.New(spinner.WithSpinner(spinner.Dot),\n\t\tspinner.WithStyle(common.Styles.Spinner))\n\treturn &Readme{\n\t\tcode:      readme,\n\t\tcommon:    common,\n\t\tspinner:   s,\n\t\tisLoading: true,\n\t}\n}\n\n// Path implements common.TabComponent.\nfunc (r *Readme) Path() string {\n\treturn \"\"\n}\n\n// TabName returns the name of the tab.\nfunc (r *Readme) TabName() string {\n\treturn \"Readme\"\n}\n\n// SetSize implements common.Component.\nfunc (r *Readme) SetSize(width, height int) {\n\tr.common.SetSize(width, height)\n\tr.code.SetSize(width, height)\n}\n\n// ShortHelp implements help.KeyMap.\nfunc (r *Readme) ShortHelp() []key.Binding {\n\tb := []key.Binding{\n\t\tr.common.KeyMap.UpDown,\n\t}\n\treturn b\n}\n\n// FullHelp implements help.KeyMap.\nfunc (r *Readme) FullHelp() [][]key.Binding {\n\tk := r.code.KeyMap\n\tb := [][]key.Binding{\n\t\t{\n\t\t\tk.PageDown,\n\t\t\tk.PageUp,\n\t\t\tk.HalfPageDown,\n\t\t\tk.HalfPageUp,\n\t\t},\n\t\t{\n\t\t\tk.Down,\n\t\t\tk.Up,\n\t\t\tr.common.KeyMap.GotoTop,\n\t\t\tr.common.KeyMap.GotoBottom,\n\t\t},\n\t}\n\treturn b\n}\n\n// Init implements tea.Model.\nfunc (r *Readme) Init() tea.Cmd {\n\tr.isLoading = true\n\treturn tea.Batch(r.spinner.Tick, r.updateReadmeCmd)\n}\n\n// Update implements tea.Model.\nfunc (r *Readme) Update(msg tea.Msg) (common.Model, tea.Cmd) {\n\tcmds := make([]tea.Cmd, 0)\n\tswitch msg := msg.(type) {\n\tcase RepoMsg:\n\t\tr.repo = msg\n\tcase RefMsg:\n\t\tr.ref = msg\n\t\tcmds = append(cmds, r.Init())\n\tcase tea.WindowSizeMsg:\n\t\tr.SetSize(msg.Width, msg.Height)\n\tcase EmptyRepoMsg:\n\t\tcmds = append(cmds,\n\t\t\tr.code.SetContent(defaultEmptyRepoMsg(r.common.Config(),\n\t\t\t\tr.repo.Name()), \".md\"),\n\t\t)\n\tcase ReadmeMsg:\n\t\tr.isLoading = false\n\t\tr.readmePath = msg.Path\n\t\tr.code.GotoTop()\n\t\tcmds = append(cmds, r.code.SetContent(msg.Content, msg.Path))\n\tcase spinner.TickMsg:\n\t\tif r.isLoading && r.spinner.ID() == msg.ID {\n\t\t\ts, cmd := r.spinner.Update(msg)\n\t\t\tr.spinner = s\n\t\t\tif cmd != nil {\n\t\t\t\tcmds = append(cmds, cmd)\n\t\t\t}\n\t\t}\n\t}\n\tc, cmd := r.code.Update(msg)\n\tr.code = c.(*code.Code)\n\tif cmd != nil {\n\t\tcmds = append(cmds, cmd)\n\t}\n\treturn r, tea.Batch(cmds...)\n}\n\n// View implements tea.Model.\nfunc (r *Readme) View() string {\n\tif r.isLoading {\n\t\treturn renderLoading(r.common, r.spinner)\n\t}\n\treturn r.code.View()\n}\n\n// SpinnerID implements common.TabComponent.\nfunc (r *Readme) SpinnerID() int {\n\treturn r.spinner.ID()\n}\n\n// StatusBarValue implements statusbar.StatusBar.\nfunc (r *Readme) StatusBarValue() string {\n\tdir := filepath.Dir(r.readmePath)\n\tif dir == \".\" || dir == \"\" {\n\t\treturn \" \"\n\t}\n\treturn dir\n}\n\n// StatusBarInfo implements statusbar.StatusBar.\nfunc (r *Readme) StatusBarInfo() string {\n\treturn common.ScrollPercent(r.code.ScrollPosition())\n}\n\nfunc (r *Readme) updateReadmeCmd() tea.Msg {\n\tm := ReadmeMsg{}\n\tif r.repo == nil {\n\t\treturn common.ErrorMsg(common.ErrMissingRepo)\n\t}\n\trm, rp, _ := backend.Readme(r.repo, r.ref)\n\tm.Content = rm\n\tm.Path = rp\n\treturn m\n}\n"
  },
  {
    "path": "pkg/ui/pages/repo/refs.go",
    "content": "package repo\n\nimport (\n\t\"fmt\"\n\t\"sort\"\n\t\"strings\"\n\n\t\"charm.land/bubbles/v2/key\"\n\t\"charm.land/bubbles/v2/spinner\"\n\ttea \"charm.land/bubbletea/v2\"\n\t\"github.com/charmbracelet/soft-serve/git\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/common\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/components/selector\"\n)\n\n// RefMsg is a message that contains a git.Reference.\ntype RefMsg *git.Reference\n\n// RefItemsMsg is a message that contains a list of RefItem.\ntype RefItemsMsg struct {\n\tprefix string\n\titems  []selector.IdentifiableItem\n}\n\n// Refs is a component that displays a list of references.\ntype Refs struct {\n\tcommon    common.Common\n\tselector  *selector.Selector\n\trepo      proto.Repository\n\tref       *git.Reference\n\tactiveRef *git.Reference\n\trefPrefix string\n\tspinner   spinner.Model\n\tisLoading bool\n}\n\n// NewRefs creates a new Refs component.\nfunc NewRefs(common common.Common, refPrefix string) *Refs {\n\tr := &Refs{\n\t\tcommon:    common,\n\t\trefPrefix: refPrefix,\n\t\tisLoading: true,\n\t}\n\ts := selector.New(common, []selector.IdentifiableItem{}, RefItemDelegate{&common})\n\ts.SetShowFilter(false)\n\ts.SetShowHelp(false)\n\ts.SetShowPagination(false)\n\ts.SetShowStatusBar(false)\n\ts.SetShowTitle(false)\n\ts.SetFilteringEnabled(false)\n\ts.DisableQuitKeybindings()\n\tr.selector = s\n\tsp := spinner.New(spinner.WithSpinner(spinner.Dot),\n\t\tspinner.WithStyle(common.Styles.Spinner))\n\tr.spinner = sp\n\treturn r\n}\n\n// Path implements common.TabComponent.\nfunc (r *Refs) Path() string {\n\treturn \"\"\n}\n\n// TabName returns the name of the tab.\nfunc (r *Refs) TabName() string {\n\tswitch r.refPrefix {\n\tcase git.RefsHeads:\n\t\treturn \"Branches\"\n\tcase git.RefsTags:\n\t\treturn \"Tags\"\n\t}\n\treturn \"Refs\"\n}\n\n// SetSize implements common.Component.\nfunc (r *Refs) SetSize(width, height int) {\n\tr.common.SetSize(width, height)\n\tr.selector.SetSize(width, height)\n}\n\n// ShortHelp implements help.KeyMap.\nfunc (r *Refs) ShortHelp() []key.Binding {\n\tcopyKey := r.common.KeyMap.Copy\n\tcopyKey.SetHelp(\"c\", \"copy ref\")\n\tk := r.selector.KeyMap\n\treturn []key.Binding{\n\t\tr.common.KeyMap.SelectItem,\n\t\tk.CursorUp,\n\t\tk.CursorDown,\n\t\tcopyKey,\n\t}\n}\n\n// FullHelp implements help.KeyMap.\nfunc (r *Refs) FullHelp() [][]key.Binding {\n\tcopyKey := r.common.KeyMap.Copy\n\tcopyKey.SetHelp(\"c\", \"copy ref\")\n\tk := r.selector.KeyMap\n\treturn [][]key.Binding{\n\t\t{r.common.KeyMap.SelectItem},\n\t\t{\n\t\t\tk.CursorUp,\n\t\t\tk.CursorDown,\n\t\t\tk.NextPage,\n\t\t\tk.PrevPage,\n\t\t},\n\t\t{\n\t\t\tk.GoToStart,\n\t\t\tk.GoToEnd,\n\t\t\tcopyKey,\n\t\t},\n\t}\n}\n\n// Init implements tea.Model.\nfunc (r *Refs) Init() tea.Cmd {\n\tr.isLoading = true\n\treturn tea.Batch(r.spinner.Tick, r.updateItemsCmd)\n}\n\n// Update implements tea.Model.\nfunc (r *Refs) Update(msg tea.Msg) (common.Model, tea.Cmd) {\n\tcmds := make([]tea.Cmd, 0)\n\tswitch msg := msg.(type) {\n\tcase RepoMsg:\n\t\tr.selector.Select(0)\n\t\tr.repo = msg\n\tcase RefMsg:\n\t\tr.ref = msg\n\t\tcmds = append(cmds, r.Init())\n\tcase tea.WindowSizeMsg:\n\t\tr.SetSize(msg.Width, msg.Height)\n\tcase RefItemsMsg:\n\t\tif r.refPrefix == msg.prefix {\n\t\t\tcmds = append(cmds, r.selector.SetItems(msg.items))\n\t\t\ti := r.selector.SelectedItem()\n\t\t\tif i != nil {\n\t\t\t\tr.activeRef = i.(RefItem).Reference\n\t\t\t}\n\t\t\tr.isLoading = false\n\t\t}\n\tcase selector.ActiveMsg:\n\t\tswitch sel := msg.IdentifiableItem.(type) {\n\t\tcase RefItem:\n\t\t\tr.activeRef = sel.Reference\n\t\t}\n\tcase selector.SelectMsg:\n\t\tswitch i := msg.IdentifiableItem.(type) {\n\t\tcase RefItem:\n\t\t\tcmds = append(cmds,\n\t\t\t\tswitchRefCmd(i.Reference),\n\t\t\t\tswitchTabCmd(&Files{}),\n\t\t\t)\n\t\t}\n\tcase tea.KeyPressMsg:\n\t\tswitch {\n\t\tcase key.Matches(msg, r.common.KeyMap.SelectItem):\n\t\t\tcmds = append(cmds, r.selector.SelectItemCmd)\n\t\t}\n\tcase EmptyRepoMsg:\n\t\tr.ref = nil\n\t\tcmds = append(cmds, r.setItems([]selector.IdentifiableItem{}))\n\tcase spinner.TickMsg:\n\t\tif r.isLoading && r.spinner.ID() == msg.ID {\n\t\t\ts, cmd := r.spinner.Update(msg)\n\t\t\tif cmd != nil {\n\t\t\t\tcmds = append(cmds, cmd)\n\t\t\t}\n\t\t\tr.spinner = s\n\t\t}\n\t}\n\tm, cmd := r.selector.Update(msg)\n\tr.selector = m.(*selector.Selector)\n\tif cmd != nil {\n\t\tcmds = append(cmds, cmd)\n\t}\n\treturn r, tea.Batch(cmds...)\n}\n\n// View implements tea.Model.\nfunc (r *Refs) View() string {\n\tif r.isLoading {\n\t\treturn renderLoading(r.common, r.spinner)\n\t}\n\treturn r.selector.View()\n}\n\n// SpinnerID implements common.TabComponent.\nfunc (r *Refs) SpinnerID() int {\n\treturn r.spinner.ID()\n}\n\n// StatusBarValue implements statusbar.StatusBar.\nfunc (r *Refs) StatusBarValue() string {\n\tif r.activeRef == nil {\n\t\treturn \"\"\n\t}\n\treturn r.activeRef.Name().String()\n}\n\n// StatusBarInfo implements statusbar.StatusBar.\nfunc (r *Refs) StatusBarInfo() string {\n\ttotalPages := r.selector.TotalPages()\n\tif totalPages <= 1 {\n\t\treturn \"p. 1/1\"\n\t}\n\treturn fmt.Sprintf(\"p. %d/%d\", r.selector.Page()+1, totalPages)\n}\n\nfunc (r *Refs) updateItemsCmd() tea.Msg {\n\tits := make(RefItems, 0)\n\trr, err := r.repo.Open()\n\tif err != nil {\n\t\treturn common.ErrorMsg(err)\n\t}\n\trefs, err := rr.References()\n\tif err != nil {\n\t\tr.common.Logger.Debugf(\"ui: error getting references: %v\", err)\n\t\treturn common.ErrorMsg(err)\n\t}\n\tfor _, ref := range refs {\n\t\tif strings.HasPrefix(ref.Name().String(), r.refPrefix) {\n\t\t\trefItem := RefItem{\n\t\t\t\tReference: ref,\n\t\t\t}\n\n\t\t\tif ref.IsTag() {\n\t\t\t\trefItem.Tag, _ = rr.Tag(ref.Name().Short())\n\t\t\t\tif refItem.Tag != nil {\n\t\t\t\t\trefItem.Commit, _ = refItem.Tag.Commit()\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\trefItem.Commit, _ = rr.CatFileCommit(ref.ID)\n\t\t\t}\n\t\t\tits = append(its, refItem)\n\t\t}\n\t}\n\tsort.Sort(its)\n\titems := make([]selector.IdentifiableItem, len(its))\n\tfor i, it := range its {\n\t\titems[i] = it\n\t}\n\treturn RefItemsMsg{\n\t\titems:  items,\n\t\tprefix: r.refPrefix,\n\t}\n}\n\nfunc (r *Refs) setItems(items []selector.IdentifiableItem) tea.Cmd {\n\treturn func() tea.Msg {\n\t\treturn RefItemsMsg{\n\t\t\titems:  items,\n\t\t\tprefix: r.refPrefix,\n\t\t}\n\t}\n}\n\nfunc switchRefCmd(ref *git.Reference) tea.Cmd {\n\treturn func() tea.Msg {\n\t\treturn RefMsg(ref)\n\t}\n}\n\n// UpdateRefCmd gets the repository's HEAD reference and sends a RefMsg.\nfunc UpdateRefCmd(repo proto.Repository) tea.Cmd {\n\treturn func() tea.Msg {\n\t\tr, err := repo.Open()\n\t\tif err != nil {\n\t\t\treturn common.ErrorMsg(err)\n\t\t}\n\t\tbs, _ := r.Branches()\n\t\tif len(bs) == 0 {\n\t\t\treturn EmptyRepoMsg{}\n\t\t}\n\t\tref, err := r.HEAD()\n\t\tif err != nil {\n\t\t\treturn common.ErrorMsg(err)\n\t\t}\n\t\treturn RefMsg(ref)\n\t}\n}\n"
  },
  {
    "path": "pkg/ui/pages/repo/refsitem.go",
    "content": "package repo\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"strings\"\n\t\"time\"\n\n\t\"charm.land/bubbles/v2/key\"\n\t\"charm.land/bubbles/v2/list\"\n\ttea \"charm.land/bubbletea/v2\"\n\t\"charm.land/lipgloss/v2\"\n\t\"github.com/charmbracelet/soft-serve/git\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/common\"\n\t\"github.com/dustin/go-humanize\"\n\t\"github.com/muesli/reflow/truncate\"\n)\n\n// RefItem is a git reference item.\ntype RefItem struct {\n\t*git.Reference\n\t*git.Tag\n\t*git.Commit\n}\n\n// ID implements selector.IdentifiableItem.\nfunc (i RefItem) ID() string {\n\treturn i.Reference.Name().String()\n}\n\n// Title implements list.DefaultItem.\nfunc (i RefItem) Title() string {\n\treturn i.Reference.Name().Short()\n}\n\n// Description implements list.DefaultItem.\nfunc (i RefItem) Description() string {\n\treturn \"\"\n}\n\n// Short returns the short name of the reference.\nfunc (i RefItem) Short() string {\n\treturn i.Reference.Name().Short()\n}\n\n// FilterValue implements list.Item.\nfunc (i RefItem) FilterValue() string { return i.Short() }\n\n// RefItems is a list of git references.\ntype RefItems []RefItem\n\n// Len implements sort.Interface.\nfunc (cl RefItems) Len() int { return len(cl) }\n\n// Swap implements sort.Interface.\nfunc (cl RefItems) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }\n\n// Less implements sort.Interface.\nfunc (cl RefItems) Less(i, j int) bool {\n\tif cl[i].Commit != nil && cl[j].Commit != nil {\n\t\treturn cl[i].Commit.Author.When.After(cl[j].Commit.Author.When)\n\t} else if cl[i].Commit != nil && cl[j].Commit == nil {\n\t\treturn true\n\t}\n\treturn false\n}\n\n// RefItemDelegate is the delegate for the ref item.\ntype RefItemDelegate struct {\n\tcommon *common.Common\n}\n\n// Height implements list.ItemDelegate.\nfunc (d RefItemDelegate) Height() int { return 1 }\n\n// Spacing implements list.ItemDelegate.\nfunc (d RefItemDelegate) Spacing() int { return 0 }\n\n// Update implements list.ItemDelegate.\nfunc (d RefItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {\n\titem, ok := m.SelectedItem().(RefItem)\n\tif !ok {\n\t\treturn nil\n\t}\n\tswitch msg := msg.(type) {\n\tcase tea.KeyPressMsg:\n\t\tswitch {\n\t\tcase key.Matches(msg, d.common.KeyMap.Copy):\n\t\t\treturn copyCmd(item.ID(), fmt.Sprintf(\"Reference %q copied to clipboard\", item.ID()))\n\t\t}\n\t}\n\treturn nil\n}\n\n// Render implements list.ItemDelegate.\nfunc (d RefItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {\n\ti, ok := listItem.(RefItem)\n\tif !ok {\n\t\treturn\n\t}\n\n\tisTag := i.Reference.IsTag()\n\tisActive := index == m.Index()\n\ts := d.common.Styles.Ref\n\tst := s.Normal\n\tselector := \"  \"\n\tif isActive {\n\t\tst = s.Active\n\t\tselector = s.ItemSelector.String()\n\t}\n\n\thorizontalFrameSize := st.Base.GetHorizontalFrameSize()\n\tvar itemSt lipgloss.Style\n\tif isTag && isActive {\n\t\titemSt = st.ItemTag\n\t} else if isTag {\n\t\titemSt = st.ItemTag\n\t} else if isActive {\n\t\titemSt = st.Item\n\t} else {\n\t\titemSt = st.Item\n\t}\n\n\tvar sha string\n\tc := i.Commit\n\tif c != nil {\n\t\tsha = c.ID.String()[:7]\n\t}\n\n\tref := i.Short()\n\n\tvar desc string\n\tif isTag {\n\t\tif c != nil {\n\t\t\tdate := c.Committer.When.Format(\"Jan 02\")\n\t\t\tif c.Committer.When.Year() != time.Now().Year() {\n\t\t\t\tdate += fmt.Sprintf(\" %d\", c.Committer.When.Year())\n\t\t\t}\n\t\t\tdesc += \" \" + st.ItemDesc.Render(date)\n\t\t}\n\n\t\tt := i.Tag\n\t\tif t != nil {\n\t\t\tmsgSt := st.ItemDesc.Faint(false)\n\t\t\tmsg := t.Message()\n\t\t\tnl := strings.Index(msg, \"\\n\")\n\t\t\tif nl > 0 {\n\t\t\t\tmsg = msg[:nl]\n\t\t\t}\n\t\t\tmsg = strings.TrimSpace(msg)\n\t\t\tif msg != \"\" {\n\t\t\t\tmsgMargin := m.Width() -\n\t\t\t\t\thorizontalFrameSize -\n\t\t\t\t\tlipgloss.Width(selector) -\n\t\t\t\t\tlipgloss.Width(ref) -\n\t\t\t\t\tlipgloss.Width(desc) -\n\t\t\t\t\tlipgloss.Width(sha) -\n\t\t\t\t\t3 // 3 is for the paddings and truncation symbol\n\t\t\t\tif msgMargin >= 0 {\n\t\t\t\t\tmsg = common.TruncateString(msg, msgMargin)\n\t\t\t\t\tdesc = \" \" + msgSt.Render(msg) + desc\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t} else if c != nil {\n\t\tonMargin := m.Width() -\n\t\t\thorizontalFrameSize -\n\t\t\tlipgloss.Width(selector) -\n\t\t\tlipgloss.Width(ref) -\n\t\t\tlipgloss.Width(desc) -\n\t\t\tlipgloss.Width(sha) -\n\t\t\t2 // 2 is for the padding and truncation symbol\n\t\tif onMargin >= 0 {\n\t\t\ton := common.TruncateString(\"updated \"+humanize.Time(c.Committer.When), onMargin)\n\t\t\tdesc += \" \" + st.ItemDesc.Render(on)\n\t\t}\n\t}\n\n\tvar hash string\n\tref = itemSt.Render(ref)\n\thashMargin := m.Width() -\n\t\thorizontalFrameSize -\n\t\tlipgloss.Width(selector) -\n\t\tlipgloss.Width(ref) -\n\t\tlipgloss.Width(desc) -\n\t\tlipgloss.Width(sha) -\n\t\t1 // 1 is for the left padding\n\tif hashMargin >= 0 {\n\t\thash = strings.Repeat(\" \", hashMargin) + st.ItemHash.\n\t\t\tAlign(lipgloss.Right).\n\t\t\tPaddingLeft(1).\n\t\t\tRender(sha)\n\t}\n\tfmt.Fprint(w, //nolint:errcheck\n\t\td.common.Zone.Mark(\n\t\t\ti.ID(),\n\t\t\tst.Base.Render(\n\t\t\t\tlipgloss.JoinHorizontal(lipgloss.Top,\n\t\t\t\t\ttruncate.String(selector+ref+desc+hash,\n\t\t\t\t\t\tuint(m.Width()-horizontalFrameSize)), //nolint:gosec\n\t\t\t\t),\n\t\t\t),\n\t\t),\n\t)\n}\n"
  },
  {
    "path": "pkg/ui/pages/repo/repo.go",
    "content": "package repo\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"charm.land/bubbles/v2/help\"\n\t\"charm.land/bubbles/v2/key\"\n\t\"charm.land/bubbles/v2/spinner\"\n\ttea \"charm.land/bubbletea/v2\"\n\t\"charm.land/lipgloss/v2\"\n\t\"github.com/charmbracelet/soft-serve/git\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/common\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/components/footer\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/components/selector\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/components/statusbar\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/components/tabs\"\n)\n\ntype state int\n\nconst (\n\tloadingState state = iota\n\treadyState\n)\n\n// EmptyRepoMsg is a message to indicate that the repository is empty.\ntype EmptyRepoMsg struct{}\n\n// CopyURLMsg is a message to copy the URL of the current repository.\ntype CopyURLMsg struct{}\n\n// RepoMsg is a message that contains a git.Repository.\ntype RepoMsg proto.Repository //nolint:revive\n\n// GoBackMsg is a message to go back to the previous view.\ntype GoBackMsg struct{}\n\n// CopyMsg is a message to indicate copied text.\ntype CopyMsg struct {\n\tText    string\n\tMessage string\n}\n\n// SwitchTabMsg is a message to switch tabs.\ntype SwitchTabMsg common.TabComponent\n\n// Repo is a view for a git repository.\ntype Repo struct {\n\tcommon       common.Common\n\tselectedRepo proto.Repository\n\tactiveTab    int\n\ttabs         *tabs.Tabs\n\tstatusbar    *statusbar.Model\n\tpanes        []common.TabComponent\n\tref          *git.Reference\n\tstate        state\n\tspinner      spinner.Model\n\tpanesReady   []bool\n}\n\n// New returns a new Repo.\nfunc New(c common.Common, comps ...common.TabComponent) *Repo {\n\tsb := statusbar.New(c)\n\tts := make([]string, 0)\n\tfor _, c := range comps {\n\t\tts = append(ts, c.TabName())\n\t}\n\tc.Logger = c.Logger.WithPrefix(\"ui.repo\")\n\ttb := tabs.New(c, ts)\n\t// Make sure the order matches the order of tab constants above.\n\ts := spinner.New(spinner.WithSpinner(spinner.Dot),\n\t\tspinner.WithStyle(c.Styles.Spinner))\n\tr := &Repo{\n\t\tcommon:     c,\n\t\ttabs:       tb,\n\t\tstatusbar:  sb,\n\t\tpanes:      comps,\n\t\tstate:      loadingState,\n\t\tspinner:    s,\n\t\tpanesReady: make([]bool, len(comps)),\n\t}\n\treturn r\n}\n\nfunc (r *Repo) getMargins() (int, int) {\n\thh := lipgloss.Height(r.headerView())\n\thm := r.common.Styles.Repo.Body.GetVerticalFrameSize() +\n\t\thh +\n\t\tr.common.Styles.Repo.Header.GetVerticalFrameSize() +\n\t\tr.common.Styles.StatusBar.GetHeight()\n\treturn 0, hm\n}\n\n// SetSize implements common.Component.\nfunc (r *Repo) SetSize(width, height int) {\n\tr.common.SetSize(width, height)\n\t_, hm := r.getMargins()\n\tr.tabs.SetSize(width, height-hm)\n\tr.statusbar.SetSize(width, height-hm)\n\tfor _, p := range r.panes {\n\t\tp.SetSize(width, height-hm)\n\t}\n}\n\n// Path returns the current component path.\nfunc (r *Repo) Path() string {\n\treturn r.panes[r.activeTab].Path()\n}\n\nfunc (r *Repo) commonHelp() []key.Binding {\n\tb := make([]key.Binding, 0)\n\tback := r.common.KeyMap.Back\n\tback.SetHelp(\"esc\", \"back to menu\")\n\ttab := r.common.KeyMap.Section\n\ttab.SetHelp(\"tab\", \"switch tab\")\n\tb = append(b, back)\n\tb = append(b, tab)\n\treturn b\n}\n\n// ShortHelp implements help.KeyMap.\nfunc (r *Repo) ShortHelp() []key.Binding {\n\tb := r.commonHelp()\n\tb = append(b, r.panes[r.activeTab].(help.KeyMap).ShortHelp()...)\n\treturn b\n}\n\n// FullHelp implements help.KeyMap.\nfunc (r *Repo) FullHelp() [][]key.Binding {\n\tb := make([][]key.Binding, 0)\n\tb = append(b, r.commonHelp())\n\tb = append(b, r.panes[r.activeTab].(help.KeyMap).FullHelp()...)\n\treturn b\n}\n\n// Init implements tea.View.\nfunc (r *Repo) Init() tea.Cmd {\n\tr.state = loadingState\n\tr.activeTab = 0\n\treturn tea.Batch(\n\t\tr.tabs.Init(),\n\t\tr.statusbar.Init(),\n\t\tr.spinner.Tick,\n\t)\n}\n\n// Update implements tea.Model.\nfunc (r *Repo) Update(msg tea.Msg) (common.Model, tea.Cmd) {\n\tcmds := make([]tea.Cmd, 0)\n\tswitch msg := msg.(type) {\n\tcase RepoMsg:\n\t\t// Set the state to loading when we get a new repository.\n\t\tr.selectedRepo = msg\n\t\tcmds = append(cmds,\n\t\t\tr.Init(),\n\t\t\t// This will set the selected repo in each pane's model.\n\t\t\tr.updateModels(msg),\n\t\t)\n\tcase RefMsg:\n\t\tr.ref = msg\n\t\tcmds = append(cmds, r.updateModels(msg))\n\t\tr.state = readyState\n\tcase tabs.SelectTabMsg:\n\t\tr.activeTab = int(msg)\n\t\tt, cmd := r.tabs.Update(msg)\n\t\tr.tabs = t.(*tabs.Tabs)\n\t\tif cmd != nil {\n\t\t\tcmds = append(cmds, cmd)\n\t\t}\n\tcase tabs.ActiveTabMsg:\n\t\tr.activeTab = int(msg)\n\tcase tea.KeyPressMsg, tea.MouseClickMsg:\n\t\tt, cmd := r.tabs.Update(msg)\n\t\tr.tabs = t.(*tabs.Tabs)\n\t\tif cmd != nil {\n\t\t\tcmds = append(cmds, cmd)\n\t\t}\n\t\tif r.selectedRepo != nil {\n\t\t\turlID := fmt.Sprintf(\"%s-url\", r.selectedRepo.Name())\n\t\t\tcmd := r.common.CloneCmd(r.common.Config().SSH.PublicURL, r.selectedRepo.Name())\n\t\t\tif msg, ok := msg.(tea.MouseMsg); ok && r.common.Zone.Get(urlID).InBounds(msg) {\n\t\t\t\tcmds = append(cmds, copyCmd(cmd, \"Command copied to clipboard\"))\n\t\t\t}\n\t\t}\n\t\tswitch msg := msg.(type) {\n\t\tcase tea.MouseClickMsg:\n\t\t\tswitch msg.Button {\n\t\t\tcase tea.MouseLeft:\n\t\t\t\tswitch {\n\t\t\t\tcase r.common.Zone.Get(\"repo-help\").InBounds(msg):\n\t\t\t\t\tcmds = append(cmds, footer.ToggleFooterCmd)\n\t\t\t\t}\n\t\t\tcase tea.MouseRight:\n\t\t\t\tswitch {\n\t\t\t\tcase r.common.Zone.Get(\"repo-main\").InBounds(msg):\n\t\t\t\t\tcmds = append(cmds, goBackCmd)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tswitch msg := msg.(type) {\n\t\tcase tea.KeyPressMsg:\n\t\t\tswitch {\n\t\t\tcase key.Matches(msg, r.common.KeyMap.Back):\n\t\t\t\tcmds = append(cmds, goBackCmd)\n\t\t\t}\n\t\t}\n\tcase CopyMsg:\n\t\ttxt := msg.Text\n\t\tif cfg := r.common.Config(); cfg != nil {\n\t\t\tcmds = append(cmds, tea.SetClipboard(txt))\n\t\t}\n\t\tr.statusbar.SetStatus(\"\", msg.Message, \"\", \"\")\n\tcase ReadmeMsg:\n\t\tcmds = append(cmds, r.updateTabComponent(&Readme{}, msg))\n\tcase FileItemsMsg, FileContentMsg:\n\t\tcmds = append(cmds, r.updateTabComponent(&Files{}, msg))\n\tcase LogItemsMsg, LogDiffMsg, LogCountMsg:\n\t\tcmds = append(cmds, r.updateTabComponent(&Log{}, msg))\n\tcase RefItemsMsg:\n\t\tcmds = append(cmds, r.updateTabComponent(&Refs{refPrefix: msg.prefix}, msg))\n\tcase StashListMsg, StashPatchMsg:\n\t\tcmds = append(cmds, r.updateTabComponent(&Stash{}, msg))\n\t// We have two spinners, one is used to when loading the repository and the\n\t// other is used when loading the log.\n\t// Check if the spinner ID matches the spinner model.\n\tcase spinner.TickMsg:\n\t\tif r.state == loadingState && r.spinner.ID() == msg.ID {\n\t\t\ts, cmd := r.spinner.Update(msg)\n\t\t\tr.spinner = s\n\t\t\tif cmd != nil {\n\t\t\t\tcmds = append(cmds, cmd)\n\t\t\t}\n\t\t} else {\n\t\t\tfor i, c := range r.panes {\n\t\t\t\tif c.SpinnerID() == msg.ID {\n\t\t\t\t\tm, cmd := c.Update(msg)\n\t\t\t\t\tr.panes[i] = m.(common.TabComponent)\n\t\t\t\t\tif cmd != nil {\n\t\t\t\t\t\tcmds = append(cmds, cmd)\n\t\t\t\t\t}\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\tcase tea.WindowSizeMsg:\n\t\tr.SetSize(msg.Width, msg.Height)\n\t\tcmds = append(cmds, r.updateModels(msg))\n\tcase EmptyRepoMsg:\n\t\tr.ref = nil\n\t\tr.state = readyState\n\t\tcmds = append(cmds, r.updateModels(msg))\n\tcase common.ErrorMsg:\n\t\tr.state = readyState\n\tcase SwitchTabMsg:\n\t\tfor i, c := range r.panes {\n\t\t\tif c.TabName() == msg.TabName() {\n\t\t\t\tcmds = append(cmds, tabs.SelectTabCmd(i))\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\tactive := r.panes[r.activeTab]\n\tm, cmd := active.Update(msg)\n\tr.panes[r.activeTab] = m.(common.TabComponent)\n\tif cmd != nil {\n\t\tcmds = append(cmds, cmd)\n\t}\n\n\t// Update the status bar on these events\n\t// Must come after we've updated the active tab\n\tswitch msg.(type) {\n\tcase RepoMsg, RefMsg, tabs.ActiveTabMsg, tea.KeyPressMsg,\n\t\ttea.MouseClickMsg, tea.MouseWheelMsg, FileItemsMsg, FileContentMsg,\n\t\tFileBlameMsg, selector.ActiveMsg, LogItemsMsg, GoBackMsg, LogDiffMsg,\n\t\tEmptyRepoMsg, StashListMsg, StashPatchMsg:\n\t\tr.setStatusBarInfo()\n\t}\n\n\ts, cmd := r.statusbar.Update(msg)\n\tr.statusbar = s.(*statusbar.Model)\n\tif cmd != nil {\n\t\tcmds = append(cmds, cmd)\n\t}\n\n\treturn r, tea.Batch(cmds...)\n}\n\n// View implements tea.Model.\nfunc (r *Repo) View() string {\n\twm, hm := r.getMargins()\n\thm += r.common.Styles.Tabs.GetHeight() +\n\t\tr.common.Styles.Tabs.GetVerticalFrameSize()\n\ts := r.common.Styles.Repo.Base.\n\t\tWidth(r.common.Width - wm).\n\t\tHeight(r.common.Height - hm)\n\tmainStyle := r.common.Styles.Repo.Body.\n\t\tHeight(r.common.Height - hm)\n\tvar main string\n\tvar statusbar string\n\tswitch r.state {\n\tcase loadingState:\n\t\tmain = fmt.Sprintf(\"%s loading…\", r.spinner.View())\n\tcase readyState:\n\t\tmain = r.panes[r.activeTab].View()\n\t\tstatusbar = r.statusbar.View()\n\t}\n\tmain = r.common.Zone.Mark(\n\t\t\"repo-main\",\n\t\tmainStyle.Render(main),\n\t)\n\tview := lipgloss.JoinVertical(lipgloss.Left,\n\t\tr.headerView(),\n\t\tr.tabs.View(),\n\t\tmain,\n\t\tstatusbar,\n\t)\n\treturn s.Render(view)\n}\n\nfunc (r *Repo) headerView() string {\n\tif r.selectedRepo == nil {\n\t\treturn \"\"\n\t}\n\ttruncate := lipgloss.NewStyle().MaxWidth(r.common.Width)\n\theader := r.selectedRepo.ProjectName()\n\tif header == \"\" {\n\t\theader = r.selectedRepo.Name()\n\t}\n\theader = r.common.Styles.Repo.HeaderName.Render(header)\n\tdesc := strings.TrimSpace(r.selectedRepo.Description())\n\tif desc != \"\" {\n\t\theader = lipgloss.JoinVertical(lipgloss.Left,\n\t\t\theader,\n\t\t\tr.common.Styles.Repo.HeaderDesc.Render(desc),\n\t\t)\n\t}\n\turlStyle := r.common.Styles.URLStyle.\n\t\tWidth(r.common.Width - lipgloss.Width(header) - 1).\n\t\tAlign(lipgloss.Right)\n\tvar url string\n\tif cfg := r.common.Config(); cfg != nil {\n\t\turl = r.common.CloneCmd(cfg.SSH.PublicURL, r.selectedRepo.Name())\n\t}\n\turl = common.TruncateString(url, r.common.Width-lipgloss.Width(header)-1)\n\turl = r.common.Zone.Mark(\n\t\tfmt.Sprintf(\"%s-url\", r.selectedRepo.Name()),\n\t\turlStyle.Render(url),\n\t)\n\n\theader = lipgloss.JoinHorizontal(lipgloss.Top, header, url)\n\n\tstyle := r.common.Styles.Repo.Header.Width(r.common.Width)\n\treturn style.Render(\n\t\ttruncate.Render(header),\n\t)\n}\n\nfunc (r *Repo) setStatusBarInfo() {\n\tif r.selectedRepo == nil {\n\t\treturn\n\t}\n\n\tactive := r.panes[r.activeTab]\n\tkey := r.selectedRepo.Name()\n\tvalue := active.StatusBarValue()\n\tinfo := active.StatusBarInfo()\n\textra := \"*\"\n\tif r.ref != nil {\n\t\textra += \" \" + r.ref.Name().Short()\n\t}\n\n\tr.statusbar.SetStatus(key, value, info, extra)\n}\n\nfunc (r *Repo) updateTabComponent(c common.TabComponent, msg tea.Msg) tea.Cmd {\n\tcmds := make([]tea.Cmd, 0)\n\tfor i, b := range r.panes {\n\t\tif b.TabName() == c.TabName() {\n\t\t\tm, cmd := b.Update(msg)\n\t\t\tr.panes[i] = m.(common.TabComponent)\n\t\t\tif cmd != nil {\n\t\t\t\tcmds = append(cmds, cmd)\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t}\n\treturn tea.Batch(cmds...)\n}\n\nfunc (r *Repo) updateModels(msg tea.Msg) tea.Cmd {\n\tcmds := make([]tea.Cmd, 0)\n\tfor i, b := range r.panes {\n\t\tm, cmd := b.Update(msg)\n\t\tr.panes[i] = m.(common.TabComponent)\n\t\tif cmd != nil {\n\t\t\tcmds = append(cmds, cmd)\n\t\t}\n\t}\n\treturn tea.Batch(cmds...)\n}\n\nfunc copyCmd(text, msg string) tea.Cmd {\n\treturn func() tea.Msg {\n\t\treturn CopyMsg{\n\t\t\tText:    text,\n\t\t\tMessage: msg,\n\t\t}\n\t}\n}\n\nfunc goBackCmd() tea.Msg {\n\treturn GoBackMsg{}\n}\n\nfunc switchTabCmd(m common.TabComponent) tea.Cmd {\n\treturn func() tea.Msg {\n\t\treturn SwitchTabMsg(m)\n\t}\n}\n\nfunc renderLoading(c common.Common, s spinner.Model) string {\n\tmsg := fmt.Sprintf(\"%s loading…\", s.View())\n\treturn c.Styles.SpinnerContainer.\n\t\tHeight(c.Height).\n\t\tRender(msg)\n}\n"
  },
  {
    "path": "pkg/ui/pages/repo/stash.go",
    "content": "package repo\n\nimport (\n\t\"fmt\"\n\n\t\"charm.land/bubbles/v2/key\"\n\t\"charm.land/bubbles/v2/spinner\"\n\ttea \"charm.land/bubbletea/v2\"\n\t\"charm.land/lipgloss/v2\"\n\tgitm \"github.com/aymanbagabas/git-module\"\n\t\"github.com/charmbracelet/soft-serve/git\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/common\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/components/code\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/components/selector\"\n)\n\ntype stashState int\n\nconst (\n\tstashStateLoading stashState = iota\n\tstashStateList\n\tstashStatePatch\n)\n\n// StashListMsg is a message sent when the stash list is loaded.\ntype StashListMsg []*gitm.Stash\n\n// StashPatchMsg is a message sent when the stash patch is loaded.\ntype StashPatchMsg struct{ *git.Diff }\n\n// Stash is the stash component page.\ntype Stash struct {\n\tcommon       common.Common\n\tcode         *code.Code\n\tref          RefMsg\n\trepo         proto.Repository\n\tspinner      spinner.Model\n\tlist         *selector.Selector\n\tstate        stashState\n\tcurrentPatch StashPatchMsg\n}\n\n// NewStash creates a new stash model.\nfunc NewStash(common common.Common) *Stash {\n\tcode := code.New(common, \"\", \"\")\n\ts := spinner.New(spinner.WithSpinner(spinner.Dot),\n\t\tspinner.WithStyle(common.Styles.Spinner))\n\tselector := selector.New(common, []selector.IdentifiableItem{}, StashItemDelegate{&common})\n\tselector.SetShowFilter(false)\n\tselector.SetShowHelp(false)\n\tselector.SetShowPagination(false)\n\tselector.SetShowStatusBar(false)\n\tselector.SetShowTitle(false)\n\tselector.SetFilteringEnabled(false)\n\tselector.DisableQuitKeybindings()\n\tselector.KeyMap.NextPage = common.KeyMap.NextPage\n\tselector.KeyMap.PrevPage = common.KeyMap.PrevPage\n\treturn &Stash{\n\t\tcode:    code,\n\t\tcommon:  common,\n\t\tspinner: s,\n\t\tlist:    selector,\n\t}\n}\n\n// Path implements common.TabComponent.\nfunc (s *Stash) Path() string {\n\treturn \"\"\n}\n\n// TabName returns the name of the tab.\nfunc (s *Stash) TabName() string {\n\treturn \"Stash\"\n}\n\n// SetSize implements common.Component.\nfunc (s *Stash) SetSize(width, height int) {\n\ts.common.SetSize(width, height)\n\ts.code.SetSize(width, height)\n\ts.list.SetSize(width, height)\n}\n\n// ShortHelp implements help.KeyMap.\nfunc (s *Stash) ShortHelp() []key.Binding {\n\treturn []key.Binding{\n\t\ts.common.KeyMap.Select,\n\t\ts.common.KeyMap.Back,\n\t\ts.common.KeyMap.UpDown,\n\t}\n}\n\n// FullHelp implements help.KeyMap.\nfunc (s *Stash) FullHelp() [][]key.Binding {\n\tb := [][]key.Binding{\n\t\t{\n\t\t\ts.common.KeyMap.Select,\n\t\t\ts.common.KeyMap.Back,\n\t\t\ts.common.KeyMap.Copy,\n\t\t},\n\t\t{\n\t\t\ts.code.KeyMap.Down,\n\t\t\ts.code.KeyMap.Up,\n\t\t\ts.common.KeyMap.GotoTop,\n\t\t\ts.common.KeyMap.GotoBottom,\n\t\t},\n\t}\n\treturn b\n}\n\n// StatusBarValue implements common.Component.\nfunc (s *Stash) StatusBarValue() string {\n\titem, ok := s.list.SelectedItem().(StashItem)\n\tif !ok {\n\t\treturn \" \"\n\t}\n\tidx := s.list.Index()\n\treturn fmt.Sprintf(\"stash@{%d}: %s\", idx, item.Title())\n}\n\n// StatusBarInfo implements common.Component.\nfunc (s *Stash) StatusBarInfo() string {\n\tswitch s.state {\n\tcase stashStateList:\n\t\ttotalPages := s.list.TotalPages()\n\t\tif totalPages <= 1 {\n\t\t\treturn \"p. 1/1\"\n\t\t}\n\t\treturn fmt.Sprintf(\"p. %d/%d\", s.list.Page()+1, totalPages)\n\tcase stashStatePatch:\n\t\treturn common.ScrollPercent(s.code.ScrollPosition())\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// SpinnerID implements common.Component.\nfunc (s *Stash) SpinnerID() int {\n\treturn s.spinner.ID()\n}\n\n// Init initializes the model.\nfunc (s *Stash) Init() tea.Cmd {\n\ts.state = stashStateLoading\n\treturn tea.Batch(s.spinner.Tick, s.fetchStash)\n}\n\n// Update updates the model.\nfunc (s *Stash) Update(msg tea.Msg) (common.Model, tea.Cmd) {\n\tcmds := make([]tea.Cmd, 0)\n\tswitch msg := msg.(type) {\n\tcase RepoMsg:\n\t\ts.repo = msg\n\tcase RefMsg:\n\t\ts.ref = msg\n\t\ts.list.Select(0)\n\t\tcmds = append(cmds, s.Init())\n\tcase tea.WindowSizeMsg:\n\t\ts.SetSize(msg.Width, msg.Height)\n\tcase spinner.TickMsg:\n\t\tif s.state == stashStateLoading && s.spinner.ID() == msg.ID {\n\t\t\tsp, cmd := s.spinner.Update(msg)\n\t\t\ts.spinner = sp\n\t\t\tif cmd != nil {\n\t\t\t\tcmds = append(cmds, cmd)\n\t\t\t}\n\t\t}\n\tcase tea.KeyPressMsg:\n\t\tswitch s.state {\n\t\tcase stashStateList:\n\t\t\tswitch {\n\t\t\tcase key.Matches(msg, s.common.KeyMap.BackItem):\n\t\t\t\tcmds = append(cmds, goBackCmd)\n\t\t\tcase key.Matches(msg, s.common.KeyMap.Copy):\n\t\t\t\tcmds = append(cmds, copyCmd(s.list.SelectedItem().(StashItem).Title(), \"Stash message copied to clipboard\"))\n\t\t\t}\n\t\tcase stashStatePatch:\n\t\t\tswitch {\n\t\t\tcase key.Matches(msg, s.common.KeyMap.BackItem):\n\t\t\t\tcmds = append(cmds, goBackCmd)\n\t\t\tcase key.Matches(msg, s.common.KeyMap.Copy):\n\t\t\t\tif s.currentPatch.Diff != nil {\n\t\t\t\t\tpatch := s.currentPatch.Diff\n\t\t\t\t\tcmds = append(cmds, copyCmd(patch.Patch(), \"Stash patch copied to clipboard\"))\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\tcase StashListMsg:\n\t\ts.state = stashStateList\n\t\titems := make([]selector.IdentifiableItem, len(msg))\n\t\tfor i, stash := range msg {\n\t\t\titems[i] = StashItem{stash}\n\t\t}\n\t\tcmds = append(cmds, s.list.SetItems(items))\n\tcase StashPatchMsg:\n\t\ts.state = stashStatePatch\n\t\ts.currentPatch = msg\n\t\tif msg.Diff != nil {\n\t\t\ttitle := s.common.Styles.Stash.Title.Render(s.list.SelectedItem().(StashItem).Title())\n\t\t\tcontent := lipgloss.JoinVertical(lipgloss.Left,\n\t\t\t\ttitle,\n\t\t\t\t\"\",\n\t\t\t\trenderSummary(msg.Diff, s.common.Styles, s.common.Width),\n\t\t\t\trenderDiff(msg.Diff, s.common.Width),\n\t\t\t)\n\t\t\tcmds = append(cmds, s.code.SetContent(content, \".diff\"))\n\t\t\ts.code.GotoTop()\n\t\t}\n\tcase selector.SelectMsg:\n\t\tswitch msg.IdentifiableItem.(type) {\n\t\tcase StashItem:\n\t\t\tcmds = append(cmds, s.fetchStashPatch)\n\t\t}\n\tcase GoBackMsg:\n\t\tif s.state == stashStateList {\n\t\t\ts.list.Select(0)\n\t\t}\n\t\ts.state = stashStateList\n\t}\n\tswitch s.state {\n\tcase stashStateList:\n\t\tl, cmd := s.list.Update(msg)\n\t\ts.list = l.(*selector.Selector)\n\t\tif cmd != nil {\n\t\t\tcmds = append(cmds, cmd)\n\t\t}\n\tcase stashStatePatch:\n\t\tc, cmd := s.code.Update(msg)\n\t\ts.code = c.(*code.Code)\n\t\tif cmd != nil {\n\t\t\tcmds = append(cmds, cmd)\n\t\t}\n\t}\n\treturn s, tea.Batch(cmds...)\n}\n\n// View returns the view.\nfunc (s *Stash) View() string {\n\tswitch s.state {\n\tcase stashStateLoading:\n\t\treturn renderLoading(s.common, s.spinner)\n\tcase stashStateList:\n\t\treturn s.list.View()\n\tcase stashStatePatch:\n\t\treturn s.code.View()\n\t}\n\treturn \"\"\n}\n\nfunc (s *Stash) fetchStash() tea.Msg {\n\tif s.repo == nil {\n\t\treturn StashListMsg(nil)\n\t}\n\n\tr, err := s.repo.Open()\n\tif err != nil {\n\t\treturn common.ErrorMsg(err)\n\t}\n\n\tstash, err := r.StashList()\n\tif err != nil {\n\t\treturn common.ErrorMsg(err)\n\t}\n\n\treturn StashListMsg(stash)\n}\n\nfunc (s *Stash) fetchStashPatch() tea.Msg {\n\tif s.repo == nil {\n\t\treturn StashPatchMsg{nil}\n\t}\n\n\tr, err := s.repo.Open()\n\tif err != nil {\n\t\treturn common.ErrorMsg(err)\n\t}\n\n\tdiff, err := r.StashDiff(s.list.Index())\n\tif err != nil {\n\t\treturn common.ErrorMsg(err)\n\t}\n\n\treturn StashPatchMsg{diff}\n}\n"
  },
  {
    "path": "pkg/ui/pages/repo/stashitem.go",
    "content": "package repo\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\n\t\"charm.land/bubbles/v2/key\"\n\t\"charm.land/bubbles/v2/list\"\n\ttea \"charm.land/bubbletea/v2\"\n\tgitm \"github.com/aymanbagabas/git-module\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/common\"\n)\n\n// StashItem represents a stash item.\ntype StashItem struct{ *gitm.Stash }\n\n// ID returns the ID of the stash item.\nfunc (i StashItem) ID() string {\n\treturn fmt.Sprintf(\"stash@{%d}\", i.Index)\n}\n\n// Title returns the title of the stash item.\nfunc (i StashItem) Title() string {\n\treturn i.Message\n}\n\n// Description returns the description of the stash item.\nfunc (i StashItem) Description() string {\n\treturn \"\"\n}\n\n// FilterValue implements list.Item.\nfunc (i StashItem) FilterValue() string { return i.Title() }\n\n// StashItems is a list of stash items.\ntype StashItems []StashItem\n\n// Len implements sort.Interface.\nfunc (cl StashItems) Len() int { return len(cl) }\n\n// Swap implements sort.Interface.\nfunc (cl StashItems) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }\n\n// Less implements sort.Interface.\nfunc (cl StashItems) Less(i, j int) bool {\n\treturn cl[i].Index < cl[j].Index\n}\n\n// StashItemDelegate is a delegate for stash items.\ntype StashItemDelegate struct {\n\tcommon *common.Common\n}\n\n// Height returns the height of the stash item list. Implements list.ItemDelegate.\nfunc (d StashItemDelegate) Height() int { return 1 }\n\n// Spacing implements list.ItemDelegate.\nfunc (d StashItemDelegate) Spacing() int { return 0 }\n\n// Update implements list.ItemDelegate.\nfunc (d StashItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {\n\titem, ok := m.SelectedItem().(StashItem)\n\tif !ok {\n\t\treturn nil\n\t}\n\n\tswitch msg := msg.(type) {\n\tcase tea.KeyPressMsg:\n\t\tswitch {\n\t\tcase key.Matches(msg, d.common.KeyMap.Copy):\n\t\t\treturn copyCmd(item.Title(), fmt.Sprintf(\"Stash message %q copied to clipboard\", item.Title()))\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// Render implements list.ItemDelegate.\nfunc (d StashItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {\n\titem, ok := listItem.(StashItem)\n\tif !ok {\n\t\treturn\n\t}\n\n\ts := d.common.Styles.Stash\n\n\tst := s.Normal.Message\n\tselector := \" \"\n\tif index == m.Index() {\n\t\tselector = \"> \"\n\t\tst = s.Active.Message\n\t}\n\n\tselector = s.Selector.Render(selector)\n\ttitle := st.Render(item.Title())\n\tfmt.Fprint(w, d.common.Zone.Mark( //nolint:errcheck\n\t\titem.ID(),\n\t\tcommon.TruncateString(fmt.Sprintf(\"%s%s\",\n\t\t\tselector,\n\t\t\ttitle,\n\t\t), m.Width()-\n\t\t\ts.Selector.GetWidth()-\n\t\t\tst.GetHorizontalFrameSize(),\n\t\t),\n\t))\n}\n"
  },
  {
    "path": "pkg/ui/pages/selection/item.go",
    "content": "package selection\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"charm.land/bubbles/v2/key\"\n\t\"charm.land/bubbles/v2/list\"\n\ttea \"charm.land/bubbletea/v2\"\n\t\"charm.land/lipgloss/v2\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/common\"\n\t\"github.com/dustin/go-humanize\"\n)\n\nvar _ sort.Interface = Items{}\n\n// Items is a list of Item.\ntype Items []Item\n\n// Len implements sort.Interface.\nfunc (it Items) Len() int {\n\treturn len(it)\n}\n\n// Less implements sort.Interface.\nfunc (it Items) Less(i int, j int) bool {\n\tif it[i].lastUpdate == nil && it[j].lastUpdate != nil {\n\t\treturn false\n\t}\n\tif it[i].lastUpdate != nil && it[j].lastUpdate == nil {\n\t\treturn true\n\t}\n\tif it[i].lastUpdate == nil && it[j].lastUpdate == nil {\n\t\treturn it[i].repo.Name() < it[j].repo.Name()\n\t}\n\treturn it[i].lastUpdate.After(*it[j].lastUpdate)\n}\n\n// Swap implements sort.Interface.\nfunc (it Items) Swap(i int, j int) {\n\tit[i], it[j] = it[j], it[i]\n}\n\n// Item represents a single item in the selector.\ntype Item struct {\n\trepo       proto.Repository\n\tlastUpdate *time.Time\n\tcmd        string\n}\n\n// New creates a new Item.\nfunc NewItem(c common.Common, repo proto.Repository) (Item, error) {\n\tvar lastUpdate *time.Time\n\tlu := repo.UpdatedAt()\n\tif !lu.IsZero() {\n\t\tlastUpdate = &lu\n\t}\n\tvar cmd string\n\tif cfg := c.Config(); cfg != nil {\n\t\tcmd = c.CloneCmd(cfg.SSH.PublicURL, repo.Name())\n\t}\n\treturn Item{\n\t\trepo:       repo,\n\t\tlastUpdate: lastUpdate,\n\t\tcmd:        cmd,\n\t}, nil\n}\n\n// ID implements selector.IdentifiableItem.\nfunc (i Item) ID() string {\n\treturn i.repo.Name()\n}\n\n// Title returns the item title. Implements list.DefaultItem.\nfunc (i Item) Title() string {\n\tname := i.repo.ProjectName()\n\tif name == \"\" {\n\t\tname = i.repo.Name()\n\t}\n\n\treturn name\n}\n\n// Description returns the item description. Implements list.DefaultItem.\nfunc (i Item) Description() string { return strings.TrimSpace(i.repo.Description()) }\n\n// FilterValue implements list.Item.\nfunc (i Item) FilterValue() string { return i.Title() }\n\n// Command returns the item Command view.\nfunc (i Item) Command() string {\n\treturn i.cmd\n}\n\n// ItemDelegate is the delegate for the item.\ntype ItemDelegate struct {\n\tcommon     *common.Common\n\tactivePane *pane\n\tcopiedIdx  int\n}\n\n// NewItemDelegate creates a new ItemDelegate.\nfunc NewItemDelegate(common *common.Common, activePane *pane) *ItemDelegate {\n\treturn &ItemDelegate{\n\t\tcommon:     common,\n\t\tactivePane: activePane,\n\t\tcopiedIdx:  -1,\n\t}\n}\n\n// Width returns the item width.\nfunc (d ItemDelegate) Width() int {\n\twidth := d.common.Styles.MenuItem.GetHorizontalFrameSize() + d.common.Styles.MenuItem.GetWidth()\n\treturn width\n}\n\n// Height returns the item height. Implements list.ItemDelegate.\nfunc (d *ItemDelegate) Height() int {\n\theight := d.common.Styles.MenuItem.GetVerticalFrameSize() + d.common.Styles.MenuItem.GetHeight()\n\treturn height\n}\n\n// Spacing returns the spacing between items. Implements list.ItemDelegate.\nfunc (d *ItemDelegate) Spacing() int { return 1 }\n\n// Update implements list.ItemDelegate.\nfunc (d *ItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {\n\tidx := m.Index()\n\titem, ok := m.SelectedItem().(Item)\n\tif !ok {\n\t\treturn nil\n\t}\n\tswitch msg := msg.(type) {\n\tcase tea.KeyPressMsg:\n\t\tswitch {\n\t\tcase key.Matches(msg, d.common.KeyMap.Copy):\n\t\t\td.copiedIdx = idx\n\t\t\treturn tea.Batch(\n\t\t\t\ttea.SetClipboard(item.Command()),\n\t\t\t\tm.SetItem(idx, item),\n\t\t\t)\n\t\t}\n\t}\n\treturn nil\n}\n\n// Render implements list.ItemDelegate.\nfunc (d *ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {\n\ti := listItem.(Item)\n\ts := strings.Builder{}\n\tvar matchedRunes []int\n\n\t// Conditions\n\tvar (\n\t\tisSelected = index == m.Index()\n\t\tisFiltered = m.FilterState() == list.Filtering || m.FilterState() == list.FilterApplied\n\t)\n\n\tstyles := d.common.Styles.RepoSelector.Normal\n\tif isSelected {\n\t\tstyles = d.common.Styles.RepoSelector.Active\n\t}\n\n\ttitle := i.Title()\n\ttitle = common.TruncateString(title, m.Width()-styles.Base.GetHorizontalFrameSize())\n\tif i.repo.IsPrivate() {\n\t\ttitle += \" 🔒\"\n\t}\n\tif isSelected {\n\t\ttitle += \" \"\n\t}\n\tvar updatedStr string\n\tif i.lastUpdate != nil {\n\t\tupdatedStr = fmt.Sprintf(\" Updated %s\", humanize.Time(*i.lastUpdate))\n\t}\n\tif m.Width()-styles.Base.GetHorizontalFrameSize()-lipgloss.Width(updatedStr)-lipgloss.Width(title) <= 0 {\n\t\tupdatedStr = \"\"\n\t}\n\tupdatedStyle := styles.Updated.\n\t\tAlign(lipgloss.Right).\n\t\tWidth(m.Width() - styles.Base.GetHorizontalFrameSize() - lipgloss.Width(title))\n\tupdated := updatedStyle.Render(updatedStr)\n\n\tif isFiltered && index < len(m.VisibleItems()) {\n\t\t// Get indices of matched characters\n\t\tmatchedRunes = m.MatchesForItem(index)\n\t}\n\n\tif isFiltered {\n\t\tunmatched := styles.Title.Inline(true)\n\t\tmatched := unmatched.Underline(true)\n\t\ttitle = lipgloss.StyleRunes(title, matchedRunes, matched, unmatched)\n\t}\n\ttitle = styles.Title.Render(title)\n\tdesc := i.Description()\n\tdesc = common.TruncateString(desc, m.Width()-styles.Base.GetHorizontalFrameSize())\n\tdesc = styles.Desc.Render(desc)\n\n\ts.WriteString(lipgloss.JoinHorizontal(lipgloss.Bottom, title, updated))\n\ts.WriteRune('\\n')\n\ts.WriteString(desc)\n\ts.WriteRune('\\n')\n\n\tcmd := i.Command()\n\tcmdStyler := styles.Command.Render\n\tif d.copiedIdx == index {\n\t\tcmd = \"(copied to clipboard)\"\n\t\tcmdStyler = styles.Desc.Render\n\t\td.copiedIdx = -1\n\t}\n\tcmd = common.TruncateString(cmd, m.Width()-styles.Base.GetHorizontalFrameSize())\n\ts.WriteString(cmdStyler(cmd))\n\tfmt.Fprint(w, //nolint:errcheck\n\t\td.common.Zone.Mark(i.ID(),\n\t\t\tstyles.Base.Render(s.String()),\n\t\t),\n\t)\n}\n"
  },
  {
    "path": "pkg/ui/pages/selection/selection.go",
    "content": "package selection\n\nimport (\n\t\"fmt\"\n\t\"sort\"\n\n\t\"charm.land/bubbles/v2/key\"\n\t\"charm.land/bubbles/v2/list\"\n\ttea \"charm.land/bubbletea/v2\"\n\t\"charm.land/lipgloss/v2\"\n\t\"github.com/charmbracelet/soft-serve/pkg/access\"\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/common\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/components/code\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/components/selector\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ui/components/tabs\"\n)\n\nconst (\n\tdefaultNoContent = \"No readme found.\\n\\nCreate a `.soft-serve` repository and add a `README.md` file to display readme.\"\n)\n\ntype pane int\n\nconst (\n\tselectorPane pane = iota\n\treadmePane\n\tlastPane\n)\n\nfunc (p pane) String() string {\n\treturn []string{\n\t\t\"Repositories\",\n\t\t\"About\",\n\t}[p]\n}\n\n// Selection is the model for the selection screen/page.\ntype Selection struct {\n\tcommon     common.Common\n\treadme     *code.Code\n\tselector   *selector.Selector\n\tactivePane pane\n\ttabs       *tabs.Tabs\n}\n\n// New creates a new selection model.\nfunc New(c common.Common) *Selection {\n\tts := make([]string, lastPane)\n\tfor i, b := range []pane{selectorPane, readmePane} {\n\t\tts[i] = b.String()\n\t}\n\tt := tabs.New(c, ts)\n\tt.TabSeparator = lipgloss.NewStyle()\n\tt.TabInactive = c.Styles.TopLevelNormalTab\n\tt.TabActive = c.Styles.TopLevelActiveTab\n\tt.TabDot = c.Styles.TopLevelActiveTabDot\n\tt.UseDot = true\n\tsel := &Selection{\n\t\tcommon:     c,\n\t\tactivePane: selectorPane, // start with the selector focused\n\t\ttabs:       t,\n\t}\n\treadme := code.New(c, \"\", \"\")\n\treadme.UseGlamour = true\n\treadme.NoContentStyle = c.Styles.NoContent.\n\t\tSetString(defaultNoContent)\n\tselector := selector.New(c,\n\t\t[]selector.IdentifiableItem{},\n\t\tNewItemDelegate(&c, &sel.activePane))\n\tselector.SetShowTitle(false)\n\tselector.SetShowHelp(false)\n\tselector.SetShowStatusBar(false)\n\tselector.DisableQuitKeybindings()\n\tsel.selector = selector\n\tsel.readme = readme\n\treturn sel\n}\n\nfunc (s *Selection) getMargins() (wm, hm int) {\n\twm = 0\n\thm = s.common.Styles.Tabs.GetVerticalFrameSize() +\n\t\ts.common.Styles.Tabs.GetHeight()\n\tif s.activePane == selectorPane && s.IsFiltering() {\n\t\t// hide tabs when filtering\n\t\thm = 0\n\t}\n\treturn\n}\n\n// FilterState returns the current filter state.\nfunc (s *Selection) FilterState() list.FilterState {\n\treturn s.selector.FilterState()\n}\n\n// SetSize implements common.Component.\nfunc (s *Selection) SetSize(width, height int) {\n\ts.common.SetSize(width, height)\n\twm, hm := s.getMargins()\n\ts.tabs.SetSize(width, height-hm)\n\ts.selector.SetSize(width-wm, height-hm)\n\ts.readme.SetSize(width-wm, height-hm-1) // -1 for readme status line\n}\n\n// IsFiltering returns true if the selector is currently filtering.\nfunc (s *Selection) IsFiltering() bool {\n\treturn s.FilterState() == list.Filtering\n}\n\n// ShortHelp implements help.KeyMap.\nfunc (s *Selection) ShortHelp() []key.Binding {\n\tk := s.selector.KeyMap\n\tkb := make([]key.Binding, 0)\n\tkb = append(kb,\n\t\ts.common.KeyMap.UpDown,\n\t\ts.common.KeyMap.Section,\n\t)\n\tif s.activePane == selectorPane {\n\t\tcopyKey := s.common.KeyMap.Copy\n\t\tcopyKey.SetHelp(\"c\", \"copy command\")\n\t\tkb = append(kb,\n\t\t\ts.common.KeyMap.Select,\n\t\t\tk.Filter,\n\t\t\tk.ClearFilter,\n\t\t\tcopyKey,\n\t\t)\n\t}\n\treturn kb\n}\n\n// FullHelp implements help.KeyMap.\nfunc (s *Selection) FullHelp() [][]key.Binding {\n\tb := [][]key.Binding{\n\t\t{\n\t\t\ts.common.KeyMap.Section,\n\t\t},\n\t}\n\tswitch s.activePane {\n\tcase readmePane:\n\t\tk := s.readme.KeyMap\n\t\tb = append(b, []key.Binding{\n\t\t\tk.PageDown,\n\t\t\tk.PageUp,\n\t\t})\n\t\tb = append(b, []key.Binding{\n\t\t\tk.HalfPageDown,\n\t\t\tk.HalfPageUp,\n\t\t})\n\t\tb = append(b, []key.Binding{\n\t\t\tk.Down,\n\t\t\tk.Up,\n\t\t})\n\tcase selectorPane:\n\t\tcopyKey := s.common.KeyMap.Copy\n\t\tcopyKey.SetHelp(\"c\", \"copy command\")\n\t\tk := s.selector.KeyMap\n\t\tif !s.IsFiltering() {\n\t\t\tb[0] = append(b[0],\n\t\t\t\ts.common.KeyMap.Select,\n\t\t\t\tcopyKey,\n\t\t\t)\n\t\t}\n\t\tb = append(b, []key.Binding{\n\t\t\tk.CursorUp,\n\t\t\tk.CursorDown,\n\t\t})\n\t\tb = append(b, []key.Binding{\n\t\t\tk.NextPage,\n\t\t\tk.PrevPage,\n\t\t\tk.GoToStart,\n\t\t\tk.GoToEnd,\n\t\t})\n\t\tb = append(b, []key.Binding{\n\t\t\tk.Filter,\n\t\t\tk.ClearFilter,\n\t\t\tk.CancelWhileFiltering,\n\t\t\tk.AcceptWhileFiltering,\n\t\t})\n\t}\n\treturn b\n}\n\n// Init implements tea.Model.\nfunc (s *Selection) Init() tea.Cmd {\n\tvar readmeCmd tea.Cmd\n\tcfg := s.common.Config()\n\tif cfg == nil {\n\t\treturn nil\n\t}\n\n\tctx := s.common.Context()\n\tbe := s.common.Backend()\n\tpk := s.common.PublicKey()\n\tif pk == nil && !be.AllowKeyless(ctx) {\n\t\treturn nil\n\t}\n\n\trepos, err := be.Repositories(ctx)\n\tif err != nil {\n\t\treturn common.ErrorCmd(err)\n\t}\n\tsortedItems := make(Items, 0)\n\tfor _, r := range repos {\n\t\tif r.Name() == \".soft-serve\" {\n\t\t\treadme, path, err := backend.Readme(r, nil)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\treadmeCmd = s.readme.SetContent(readme, path)\n\t\t}\n\n\t\tif r.IsHidden() {\n\t\t\tcontinue\n\t\t}\n\t\tal := be.AccessLevelByPublicKey(ctx, r.Name(), pk)\n\t\tif al >= access.ReadOnlyAccess {\n\t\t\titem, err := NewItem(s.common, r)\n\t\t\tif err != nil {\n\t\t\t\ts.common.Logger.Debugf(\"ui: failed to create item for %s: %v\", r.Name(), err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tsortedItems = append(sortedItems, item)\n\t\t}\n\t}\n\tsort.Sort(sortedItems)\n\titems := make([]selector.IdentifiableItem, len(sortedItems))\n\tfor i, it := range sortedItems {\n\t\titems[i] = it\n\t}\n\treturn tea.Batch(\n\t\ts.selector.Init(),\n\t\ts.selector.SetItems(items),\n\t\treadmeCmd,\n\t)\n}\n\n// Update implements tea.Model.\nfunc (s *Selection) Update(msg tea.Msg) (common.Model, tea.Cmd) {\n\tcmds := make([]tea.Cmd, 0)\n\tswitch msg := msg.(type) {\n\tcase tea.WindowSizeMsg:\n\t\tr, cmd := s.readme.Update(msg)\n\t\ts.readme = r.(*code.Code)\n\t\tif cmd != nil {\n\t\t\tcmds = append(cmds, cmd)\n\t\t}\n\t\tm, cmd := s.selector.Update(msg)\n\t\ts.selector = m.(*selector.Selector)\n\t\tif cmd != nil {\n\t\t\tcmds = append(cmds, cmd)\n\t\t}\n\tcase tea.KeyPressMsg, tea.MouseMsg:\n\t\tswitch msg := msg.(type) {\n\t\tcase tea.KeyPressMsg:\n\t\t\tswitch {\n\t\t\tcase key.Matches(msg, s.common.KeyMap.Back):\n\t\t\t\tcmds = append(cmds, s.selector.Init())\n\t\t\t}\n\t\t}\n\t\tt, cmd := s.tabs.Update(msg)\n\t\ts.tabs = t.(*tabs.Tabs)\n\t\tif cmd != nil {\n\t\t\tcmds = append(cmds, cmd)\n\t\t}\n\tcase tabs.ActiveTabMsg:\n\t\ts.activePane = pane(msg)\n\t}\n\tswitch s.activePane {\n\tcase readmePane:\n\t\tr, cmd := s.readme.Update(msg)\n\t\ts.readme = r.(*code.Code)\n\t\tif cmd != nil {\n\t\t\tcmds = append(cmds, cmd)\n\t\t}\n\tcase selectorPane:\n\t\tm, cmd := s.selector.Update(msg)\n\t\ts.selector = m.(*selector.Selector)\n\t\tif cmd != nil {\n\t\t\tcmds = append(cmds, cmd)\n\t\t}\n\t}\n\treturn s, tea.Batch(cmds...)\n}\n\n// View implements tea.Model.\nfunc (s *Selection) View() string {\n\tvar view string\n\twm, hm := s.getMargins()\n\tswitch s.activePane {\n\tcase selectorPane:\n\t\tss := lipgloss.NewStyle().\n\t\t\tWidth(s.common.Width - wm).\n\t\t\tHeight(s.common.Height - hm)\n\t\tview = ss.Render(s.selector.View())\n\tcase readmePane:\n\t\trs := lipgloss.NewStyle().\n\t\t\tHeight(s.common.Height - hm)\n\t\tstatus := fmt.Sprintf(\"☰ %.f%%\", s.readme.ScrollPercent()*100)\n\t\treadmeStatus := lipgloss.NewStyle().\n\t\t\tAlign(lipgloss.Right).\n\t\t\tWidth(s.common.Width - wm).\n\t\t\tForeground(s.common.Styles.InactiveBorderColor).\n\t\t\tRender(status)\n\t\tview = rs.Render(lipgloss.JoinVertical(lipgloss.Left,\n\t\t\ts.readme.View(),\n\t\t\treadmeStatus,\n\t\t))\n\t}\n\tif s.activePane != selectorPane || s.FilterState() != list.Filtering {\n\t\ttabs := s.common.Styles.Tabs.Render(s.tabs.View())\n\t\tview = lipgloss.JoinVertical(lipgloss.Left,\n\t\t\ttabs,\n\t\t\tview,\n\t\t)\n\t}\n\treturn lipgloss.JoinVertical(\n\t\tlipgloss.Left,\n\t\tview,\n\t)\n}\n"
  },
  {
    "path": "pkg/ui/styles/styles.go",
    "content": "package styles\n\nimport (\n\t\"image/color\"\n\n\t\"charm.land/lipgloss/v2\"\n)\n\n// XXX: For now, this is in its own package so that it can be shared between\n// different packages without incurring an illegal import cycle.\n\n// Styles defines styles for the UI.\ntype Styles struct {\n\tActiveBorderColor   color.Color\n\tInactiveBorderColor color.Color\n\n\tApp                  lipgloss.Style\n\tServerName           lipgloss.Style\n\tTopLevelNormalTab    lipgloss.Style\n\tTopLevelActiveTab    lipgloss.Style\n\tTopLevelActiveTabDot lipgloss.Style\n\n\tMenuItem       lipgloss.Style\n\tMenuLastUpdate lipgloss.Style\n\n\tRepoSelector struct {\n\t\tNormal struct {\n\t\t\tBase    lipgloss.Style\n\t\t\tTitle   lipgloss.Style\n\t\t\tDesc    lipgloss.Style\n\t\t\tCommand lipgloss.Style\n\t\t\tUpdated lipgloss.Style\n\t\t}\n\t\tActive struct {\n\t\t\tBase    lipgloss.Style\n\t\t\tTitle   lipgloss.Style\n\t\t\tDesc    lipgloss.Style\n\t\t\tCommand lipgloss.Style\n\t\t\tUpdated lipgloss.Style\n\t\t}\n\t}\n\n\tRepo struct {\n\t\tBase       lipgloss.Style\n\t\tTitle      lipgloss.Style\n\t\tCommand    lipgloss.Style\n\t\tBody       lipgloss.Style\n\t\tHeader     lipgloss.Style\n\t\tHeaderName lipgloss.Style\n\t\tHeaderDesc lipgloss.Style\n\t}\n\n\tFooter      lipgloss.Style\n\tBranch      lipgloss.Style\n\tHelpKey     lipgloss.Style\n\tHelpValue   lipgloss.Style\n\tHelpDivider lipgloss.Style\n\tURLStyle    lipgloss.Style\n\n\tError      lipgloss.Style\n\tErrorTitle lipgloss.Style\n\tErrorBody  lipgloss.Style\n\n\tLogItem struct {\n\t\tNormal struct {\n\t\t\tBase    lipgloss.Style\n\t\t\tHash    lipgloss.Style\n\t\t\tTitle   lipgloss.Style\n\t\t\tDesc    lipgloss.Style\n\t\t\tKeyword lipgloss.Style\n\t\t}\n\t\tActive struct {\n\t\t\tBase    lipgloss.Style\n\t\t\tHash    lipgloss.Style\n\t\t\tTitle   lipgloss.Style\n\t\t\tDesc    lipgloss.Style\n\t\t\tKeyword lipgloss.Style\n\t\t}\n\t}\n\n\tLog struct {\n\t\tCommit         lipgloss.Style\n\t\tCommitHash     lipgloss.Style\n\t\tCommitAuthor   lipgloss.Style\n\t\tCommitDate     lipgloss.Style\n\t\tCommitBody     lipgloss.Style\n\t\tCommitStatsAdd lipgloss.Style\n\t\tCommitStatsDel lipgloss.Style\n\t\tPaginator      lipgloss.Style\n\t}\n\n\tRef struct {\n\t\tNormal struct {\n\t\t\tBase     lipgloss.Style\n\t\t\tItem     lipgloss.Style\n\t\t\tItemTag  lipgloss.Style\n\t\t\tItemDesc lipgloss.Style\n\t\t\tItemHash lipgloss.Style\n\t\t}\n\t\tActive struct {\n\t\t\tBase     lipgloss.Style\n\t\t\tItem     lipgloss.Style\n\t\t\tItemTag  lipgloss.Style\n\t\t\tItemDesc lipgloss.Style\n\t\t\tItemHash lipgloss.Style\n\t\t}\n\t\tItemSelector lipgloss.Style\n\t\tPaginator    lipgloss.Style\n\t\tSelector     lipgloss.Style\n\t}\n\n\tTree struct {\n\t\tNormal struct {\n\t\t\tFileName lipgloss.Style\n\t\t\tFileDir  lipgloss.Style\n\t\t\tFileMode lipgloss.Style\n\t\t\tFileSize lipgloss.Style\n\t\t}\n\t\tActive struct {\n\t\t\tFileName lipgloss.Style\n\t\t\tFileDir  lipgloss.Style\n\t\t\tFileMode lipgloss.Style\n\t\t\tFileSize lipgloss.Style\n\t\t}\n\t\tSelector    lipgloss.Style\n\t\tFileContent lipgloss.Style\n\t\tPaginator   lipgloss.Style\n\t\tBlame       struct {\n\t\t\tHash    lipgloss.Style\n\t\t\tMessage lipgloss.Style\n\t\t\tWho     lipgloss.Style\n\t\t}\n\t}\n\n\tStash struct {\n\t\tNormal struct {\n\t\t\tMessage lipgloss.Style\n\t\t}\n\t\tActive struct {\n\t\t\tMessage lipgloss.Style\n\t\t}\n\t\tTitle    lipgloss.Style\n\t\tSelector lipgloss.Style\n\t}\n\n\tSpinner          lipgloss.Style\n\tSpinnerContainer lipgloss.Style\n\n\tNoContent lipgloss.Style\n\n\tStatusBar       lipgloss.Style\n\tStatusBarKey    lipgloss.Style\n\tStatusBarValue  lipgloss.Style\n\tStatusBarInfo   lipgloss.Style\n\tStatusBarBranch lipgloss.Style\n\tStatusBarHelp   lipgloss.Style\n\n\tTabs         lipgloss.Style\n\tTabInactive  lipgloss.Style\n\tTabActive    lipgloss.Style\n\tTabSeparator lipgloss.Style\n\n\tCode struct {\n\t\tLineDigit lipgloss.Style\n\t\tLineBar   lipgloss.Style\n\t}\n}\n\n// DefaultStyles returns default styles for the UI.\nfunc DefaultStyles() *Styles {\n\thighlightColor := lipgloss.Color(\"210\")\n\thighlightColorDim := lipgloss.Color(\"174\")\n\tselectorColor := lipgloss.Color(\"167\")\n\thashColor := lipgloss.Color(\"185\")\n\n\ts := new(Styles)\n\n\ts.ActiveBorderColor = lipgloss.Color(\"62\")\n\ts.InactiveBorderColor = lipgloss.Color(\"241\")\n\n\ts.App = lipgloss.NewStyle().\n\t\tMargin(1, 2)\n\n\ts.ServerName = lipgloss.NewStyle().\n\t\tHeight(1).\n\t\tMarginLeft(1).\n\t\tMarginBottom(1).\n\t\tPadding(0, 1).\n\t\tBackground(lipgloss.Color(\"57\")).\n\t\tForeground(lipgloss.Color(\"229\")).\n\t\tBold(true)\n\n\ts.TopLevelNormalTab = lipgloss.NewStyle().\n\t\tMarginRight(2)\n\n\ts.TopLevelActiveTab = s.TopLevelNormalTab.\n\t\tForeground(lipgloss.Color(\"36\"))\n\n\ts.TopLevelActiveTabDot = lipgloss.NewStyle().\n\t\tForeground(lipgloss.Color(\"36\"))\n\n\ts.RepoSelector.Normal.Base = lipgloss.NewStyle().\n\t\tPaddingLeft(1).\n\t\tBorder(lipgloss.Border{Left: \" \"}, false, false, false, true).\n\t\tHeight(3)\n\n\ts.RepoSelector.Normal.Title = lipgloss.NewStyle().Bold(true)\n\n\ts.RepoSelector.Normal.Desc = lipgloss.NewStyle().\n\t\tForeground(lipgloss.Color(\"243\"))\n\n\ts.RepoSelector.Normal.Command = lipgloss.NewStyle().\n\t\tForeground(lipgloss.Color(\"132\"))\n\n\ts.RepoSelector.Normal.Updated = lipgloss.NewStyle().\n\t\tForeground(lipgloss.Color(\"243\"))\n\n\ts.RepoSelector.Active.Base = s.RepoSelector.Normal.Base.\n\t\tBorderStyle(lipgloss.Border{Left: \"┃\"}).\n\t\tBorderForeground(lipgloss.Color(\"176\"))\n\n\ts.RepoSelector.Active.Title = s.RepoSelector.Normal.Title.\n\t\tForeground(lipgloss.Color(\"212\"))\n\n\ts.RepoSelector.Active.Desc = s.RepoSelector.Normal.Desc.\n\t\tForeground(lipgloss.Color(\"246\"))\n\n\ts.RepoSelector.Active.Updated = s.RepoSelector.Normal.Updated.\n\t\tForeground(lipgloss.Color(\"212\"))\n\n\ts.RepoSelector.Active.Command = s.RepoSelector.Normal.Command.\n\t\tForeground(lipgloss.Color(\"204\"))\n\n\ts.MenuItem = lipgloss.NewStyle().\n\t\tPaddingLeft(1).\n\t\tBorder(lipgloss.Border{\n\t\t\tLeft: \" \",\n\t\t}, false, false, false, true).\n\t\tHeight(3)\n\n\ts.MenuLastUpdate = lipgloss.NewStyle().\n\t\tForeground(lipgloss.Color(\"241\")).\n\t\tAlign(lipgloss.Right)\n\n\ts.Repo.Base = lipgloss.NewStyle()\n\n\ts.Repo.Title = lipgloss.NewStyle().\n\t\tPadding(0, 2)\n\n\ts.Repo.Command = lipgloss.NewStyle().\n\t\tForeground(lipgloss.Color(\"168\"))\n\n\ts.Repo.Body = lipgloss.NewStyle().\n\t\tMargin(1, 0)\n\n\ts.Repo.Header = lipgloss.NewStyle().\n\t\tMaxHeight(2).\n\t\tBorder(lipgloss.NormalBorder(), false, false, true, false).\n\t\tBorderForeground(lipgloss.Color(\"236\"))\n\n\ts.Repo.HeaderName = lipgloss.NewStyle().\n\t\tForeground(lipgloss.Color(\"212\")).\n\t\tBold(true)\n\n\ts.Repo.HeaderDesc = lipgloss.NewStyle().\n\t\tForeground(lipgloss.Color(\"243\"))\n\n\ts.Footer = lipgloss.NewStyle().\n\t\tMarginTop(1).\n\t\tPadding(0, 1).\n\t\tHeight(1)\n\n\ts.Branch = lipgloss.NewStyle().\n\t\tForeground(lipgloss.Color(\"203\")).\n\t\tBackground(lipgloss.Color(\"236\")).\n\t\tPadding(0, 1)\n\n\ts.HelpKey = lipgloss.NewStyle().\n\t\tForeground(lipgloss.Color(\"241\"))\n\n\ts.HelpValue = lipgloss.NewStyle().\n\t\tForeground(lipgloss.Color(\"239\"))\n\n\ts.HelpDivider = lipgloss.NewStyle().\n\t\tForeground(lipgloss.Color(\"237\")).\n\t\tSetString(\" • \")\n\n\ts.URLStyle = lipgloss.NewStyle().\n\t\tMarginLeft(1).\n\t\tForeground(lipgloss.Color(\"168\"))\n\n\ts.Error = lipgloss.NewStyle().\n\t\tMarginTop(2)\n\n\ts.ErrorTitle = lipgloss.NewStyle().\n\t\tForeground(lipgloss.Color(\"230\")).\n\t\tBackground(lipgloss.Color(\"204\")).\n\t\tBold(true).\n\t\tPadding(0, 1)\n\n\ts.ErrorBody = lipgloss.NewStyle().\n\t\tForeground(lipgloss.Color(\"252\")).\n\t\tMarginLeft(2)\n\n\ts.LogItem.Normal.Base = lipgloss.NewStyle().\n\t\tBorder(lipgloss.Border{\n\t\t\tLeft: \" \",\n\t\t}, false, false, false, true).\n\t\tPaddingLeft(1)\n\n\ts.LogItem.Active.Base = s.LogItem.Normal.Base.\n\t\tBorder(lipgloss.Border{\n\t\t\tLeft: \"┃\",\n\t\t}, false, false, false, true).\n\t\tBorderForeground(selectorColor)\n\n\ts.LogItem.Active.Hash = s.LogItem.Normal.Hash.\n\t\tForeground(hashColor)\n\n\ts.LogItem.Active.Hash = lipgloss.NewStyle().\n\t\tBold(true).\n\t\tForeground(highlightColor)\n\n\ts.LogItem.Normal.Title = lipgloss.NewStyle().\n\t\tForeground(lipgloss.Color(\"105\"))\n\n\ts.LogItem.Active.Title = lipgloss.NewStyle().\n\t\tForeground(highlightColor).\n\t\tBold(true)\n\n\ts.LogItem.Normal.Desc = lipgloss.NewStyle().\n\t\tForeground(lipgloss.Color(\"246\"))\n\n\ts.LogItem.Active.Desc = lipgloss.NewStyle().\n\t\tForeground(lipgloss.Color(\"95\"))\n\n\ts.LogItem.Active.Keyword = s.LogItem.Active.Desc.\n\t\tForeground(highlightColorDim)\n\n\ts.LogItem.Normal.Hash = lipgloss.NewStyle().\n\t\tForeground(hashColor)\n\n\ts.LogItem.Active.Hash = lipgloss.NewStyle().\n\t\tForeground(highlightColor)\n\n\ts.Log.Commit = lipgloss.NewStyle().\n\t\tMargin(0, 2)\n\n\ts.Log.CommitHash = lipgloss.NewStyle().\n\t\tForeground(hashColor).\n\t\tBold(true)\n\n\ts.Log.CommitBody = lipgloss.NewStyle().\n\t\tMarginTop(1).\n\t\tMarginLeft(2)\n\n\ts.Log.CommitStatsAdd = lipgloss.NewStyle().\n\t\tForeground(lipgloss.Color(\"42\")).\n\t\tBold(true)\n\n\ts.Log.CommitStatsDel = lipgloss.NewStyle().\n\t\tForeground(lipgloss.Color(\"203\")).\n\t\tBold(true)\n\n\ts.Log.Paginator = lipgloss.NewStyle().\n\t\tMargin(0).\n\t\tAlign(lipgloss.Center)\n\n\ts.Ref.Normal.Item = lipgloss.NewStyle()\n\n\ts.Ref.ItemSelector = lipgloss.NewStyle().\n\t\tForeground(selectorColor).\n\t\tSetString(\"> \")\n\n\ts.Ref.Active.Item = lipgloss.NewStyle().\n\t\tForeground(highlightColorDim)\n\n\ts.Ref.Normal.Base = lipgloss.NewStyle()\n\n\ts.Ref.Active.Base = lipgloss.NewStyle()\n\n\ts.Ref.Normal.ItemTag = lipgloss.NewStyle().\n\t\tForeground(lipgloss.Color(\"39\"))\n\n\ts.Ref.Active.ItemTag = lipgloss.NewStyle().\n\t\tBold(true).\n\t\tForeground(highlightColor)\n\n\ts.Ref.Active.Item = lipgloss.NewStyle().\n\t\tBold(true).\n\t\tForeground(highlightColor)\n\n\ts.Ref.Normal.ItemDesc = lipgloss.NewStyle().\n\t\tFaint(true)\n\n\ts.Ref.Active.ItemDesc = lipgloss.NewStyle().\n\t\tForeground(highlightColor).\n\t\tFaint(true)\n\n\ts.Ref.Normal.ItemHash = lipgloss.NewStyle().\n\t\tForeground(hashColor).\n\t\tBold(true)\n\n\ts.Ref.Active.ItemHash = lipgloss.NewStyle().\n\t\tForeground(highlightColor).\n\t\tBold(true)\n\n\ts.Ref.Paginator = s.Log.Paginator\n\n\ts.Ref.Selector = lipgloss.NewStyle()\n\n\ts.Tree.Selector = s.Tree.Normal.FileName.\n\t\tWidth(1).\n\t\tForeground(selectorColor)\n\n\ts.Tree.Normal.FileName = lipgloss.NewStyle().\n\t\tMarginLeft(1)\n\n\ts.Tree.Active.FileName = s.Tree.Normal.FileName.\n\t\tBold(true).\n\t\tForeground(highlightColor)\n\n\ts.Tree.Normal.FileDir = lipgloss.NewStyle().\n\t\tForeground(lipgloss.Color(\"39\"))\n\n\ts.Tree.Active.FileDir = lipgloss.NewStyle().\n\t\tForeground(highlightColor)\n\n\ts.Tree.Normal.FileMode = s.Tree.Active.FileName.\n\t\tWidth(10).\n\t\tForeground(lipgloss.Color(\"243\"))\n\n\ts.Tree.Active.FileMode = s.Tree.Normal.FileMode.\n\t\tForeground(highlightColorDim)\n\n\ts.Tree.Normal.FileSize = s.Tree.Normal.FileName.\n\t\tForeground(lipgloss.Color(\"243\"))\n\n\ts.Tree.Active.FileSize = s.Tree.Normal.FileName.\n\t\tForeground(highlightColorDim)\n\n\ts.Tree.FileContent = lipgloss.NewStyle()\n\n\ts.Tree.Paginator = s.Log.Paginator\n\n\ts.Tree.Blame.Hash = lipgloss.NewStyle().\n\t\tForeground(hashColor).\n\t\tBold(true)\n\n\ts.Tree.Blame.Message = lipgloss.NewStyle()\n\n\ts.Tree.Blame.Who = lipgloss.NewStyle().\n\t\tFaint(true)\n\n\ts.Spinner = lipgloss.NewStyle().\n\t\tMarginTop(1).\n\t\tMarginLeft(2).\n\t\tForeground(lipgloss.Color(\"205\"))\n\n\ts.SpinnerContainer = lipgloss.NewStyle()\n\n\ts.NoContent = lipgloss.NewStyle().\n\t\tMarginTop(1).\n\t\tMarginLeft(2).\n\t\tForeground(lipgloss.Color(\"242\"))\n\n\ts.StatusBar = lipgloss.NewStyle().\n\t\tHeight(1)\n\n\ts.StatusBarKey = lipgloss.NewStyle().\n\t\tBold(true).\n\t\tPadding(0, 1).\n\t\tBackground(lipgloss.Color(\"206\")).\n\t\tForeground(lipgloss.Color(\"228\"))\n\n\ts.StatusBarValue = lipgloss.NewStyle().\n\t\tPadding(0, 1).\n\t\tBackground(lipgloss.Color(\"235\")).\n\t\tForeground(lipgloss.Color(\"243\"))\n\n\ts.StatusBarInfo = lipgloss.NewStyle().\n\t\tPadding(0, 1).\n\t\tBackground(lipgloss.Color(\"212\")).\n\t\tForeground(lipgloss.Color(\"230\"))\n\n\ts.StatusBarBranch = lipgloss.NewStyle().\n\t\tPadding(0, 1).\n\t\tBackground(lipgloss.Color(\"62\")).\n\t\tForeground(lipgloss.Color(\"230\"))\n\n\ts.StatusBarHelp = lipgloss.NewStyle().\n\t\tPadding(0, 1).\n\t\tBackground(lipgloss.Color(\"237\")).\n\t\tForeground(lipgloss.Color(\"243\"))\n\n\ts.Tabs = lipgloss.NewStyle().\n\t\tHeight(1)\n\n\ts.TabInactive = lipgloss.NewStyle()\n\n\ts.TabActive = lipgloss.NewStyle().\n\t\tUnderline(true).\n\t\tForeground(lipgloss.Color(\"36\"))\n\n\ts.TabSeparator = lipgloss.NewStyle().\n\t\tSetString(\"│\").\n\t\tPadding(0, 1).\n\t\tForeground(lipgloss.Color(\"238\"))\n\n\ts.Code.LineDigit = lipgloss.NewStyle().Foreground(lipgloss.Color(\"239\"))\n\n\ts.Code.LineBar = lipgloss.NewStyle().Foreground(lipgloss.Color(\"236\"))\n\n\ts.Stash.Normal.Message = lipgloss.NewStyle().MarginLeft(1)\n\n\ts.Stash.Active.Message = s.Stash.Normal.Message.Foreground(selectorColor)\n\n\ts.Stash.Title = lipgloss.NewStyle().\n\t\tForeground(hashColor).\n\t\tBold(true)\n\n\ts.Stash.Selector = lipgloss.NewStyle().\n\t\tWidth(1).\n\t\tForeground(selectorColor)\n\n\treturn s\n}\n"
  },
  {
    "path": "pkg/utils/utils.go",
    "content": "package utils\n\nimport (\n\t\"fmt\"\n\t\"path\"\n\t\"strings\"\n\t\"unicode\"\n\n\t\"github.com/charmbracelet/x/ansi\"\n)\n\n// SanitizeRepo returns a sanitized version of the given repository name.\nfunc SanitizeRepo(repo string) string {\n\trepo = Sanitize(repo)\n\t// We need to use an absolute path for the path to be cleaned correctly.\n\trepo = strings.TrimPrefix(repo, \"/\")\n\trepo = \"/\" + repo\n\n\t// We're using path instead of filepath here because this is not OS dependent\n\t// looking at you Windows\n\trepo = path.Clean(repo)\n\trepo = strings.TrimSuffix(repo, \".git\")\n\treturn repo[1:]\n}\n\n// Sanitize strips ANSI escape codes from the given string.\nfunc Sanitize(s string) string {\n\treturn ansi.Strip(s)\n}\n\n// ValidateUsername returns an error if any of the given usernames are invalid.\nfunc ValidateUsername(username string) error {\n\tif username == \"\" {\n\t\treturn fmt.Errorf(\"username cannot be empty\")\n\t}\n\n\tif !unicode.IsLetter(rune(username[0])) {\n\t\treturn fmt.Errorf(\"username must start with a letter\")\n\t}\n\n\tfor _, r := range username {\n\t\tif !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' {\n\t\t\treturn fmt.Errorf(\"username can only contain letters, numbers, and hyphens\")\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// ValidateRepo returns an error if the given repository name is invalid.\nfunc ValidateRepo(repo string) error {\n\tif repo == \"\" {\n\t\treturn fmt.Errorf(\"repo cannot be empty\")\n\t}\n\n\tfor _, r := range repo {\n\t\tif !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' && r != '_' && r != '.' && r != '/' {\n\t\t\treturn fmt.Errorf(\"repo can only contain letters, numbers, hyphens, underscores, periods, and slashes\")\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/utils/utils_test.go",
    "content": "package utils\n\nimport \"testing\"\n\nfunc TestValidateRepo(t *testing.T) {\n\tt.Run(\"valid\", func(t *testing.T) {\n\t\tfor _, repo := range []string{\n\t\t\t\"lower\",\n\t\t\t\"Upper\",\n\t\t\t\"with-dash\",\n\t\t\t\"with/slash\",\n\t\t\t\"withnumb3r5\",\n\t\t\t\"with.dot\",\n\t\t\t\"with_underline\",\n\t\t} {\n\t\t\tt.Run(repo, func(t *testing.T) {\n\t\t\t\tif err := ValidateRepo(repo); err != nil {\n\t\t\t\t\tt.Errorf(\"expected no error, got %v\", err)\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t})\n\tt.Run(\"invalid\", func(t *testing.T) {\n\t\tfor _, repo := range []string{\n\t\t\t\"with$\",\n\t\t\t\"with@\",\n\t\t\t\"with!\",\n\t\t} {\n\t\t\tt.Run(repo, func(t *testing.T) {\n\t\t\t\tif err := ValidateRepo(repo); err == nil {\n\t\t\t\t\tt.Error(\"expected an error, got nil\")\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t})\n}\n\nfunc TestSanitizeRepo(t *testing.T) {\n\tcases := []struct {\n\t\tin, out string\n\t}{\n\t\t{\"lower\", \"lower\"},\n\t\t{\"Upper\", \"Upper\"},\n\t\t{\"with/slash\", \"with/slash\"},\n\t\t{\"with.dot\", \"with.dot\"},\n\t\t{\"/with_forward_slash\", \"with_forward_slash\"},\n\t\t{\"withgitsuffix.git\", \"withgitsuffix\"},\n\t}\n\tfor _, c := range cases {\n\t\tt.Run(c.in, func(t *testing.T) {\n\t\t\tif got := SanitizeRepo(c.in); got != c.out {\n\t\t\t\tt.Errorf(\"expected %q, got %q\", c.out, got)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/version/version.go",
    "content": "// Package version is used to store the version of the server during runtime.\n// The values are set during runtime in the main package.\npackage version\n\nvar (\n\t// Version is the version of the server.\n\tVersion = \"\"\n\n\t// CommitSHA is the commit SHA of the server.\n\tCommitSHA = \"\"\n\n\t// CommitDate is the commit date of the server.\n\tCommitDate = \"\"\n)\n"
  },
  {
    "path": "pkg/web/auth.go",
    "content": "package web\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"charm.land/log/v2\"\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/golang-jwt/jwt/v5\"\n)\n\n// authenticate authenticates the user from the request.\nfunc authenticate(r *http.Request) (proto.User, error) {\n\t// Prefer the Authorization header\n\tuser, err := parseAuthHdr(r)\n\tif err != nil || user == nil {\n\t\tif errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword) {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn nil, proto.ErrUserNotFound\n\t}\n\n\treturn user, nil\n}\n\n// ErrInvalidPassword is returned when the password is invalid.\nvar ErrInvalidPassword = errors.New(\"invalid password\")\n\nfunc parseUsernamePassword(ctx context.Context, username, password string) (proto.User, error) {\n\tlogger := log.FromContext(ctx)\n\tbe := backend.FromContext(ctx)\n\n\tif username != \"\" && password != \"\" {\n\t\tuser, err := be.User(ctx, username)\n\t\tif err == nil && user != nil && backend.VerifyPassword(password, user.Password()) {\n\t\t\treturn user, nil\n\t\t}\n\n\t\t// Try to authenticate using access token as the password\n\t\tuser, err = be.UserByAccessToken(ctx, password)\n\t\tif err == nil {\n\t\t\treturn user, nil\n\t\t}\n\n\t\tlogger.Error(\"invalid password or token\", \"username\", username, \"err\", err)\n\t\treturn nil, ErrInvalidPassword\n\t} else if username != \"\" {\n\t\t// Try to authenticate using access token as the username\n\t\tlogger.Debug(\"trying to authenticate using access token as username\", \"username\", username)\n\t\tuser, err := be.UserByAccessToken(ctx, username)\n\t\tif err == nil {\n\t\t\treturn user, nil\n\t\t}\n\n\t\tlogger.Error(\"failed to get user\", \"err\", err)\n\t\treturn nil, ErrInvalidToken\n\t}\n\n\treturn nil, proto.ErrUserNotFound\n}\n\n// ErrInvalidHeader is returned when the authorization header is invalid.\nvar ErrInvalidHeader = errors.New(\"invalid authorization header\")\n\nfunc parseAuthHdr(r *http.Request) (proto.User, error) {\n\t// Check for auth header\n\theader := r.Header.Get(\"Authorization\")\n\tif header == \"\" {\n\t\treturn nil, ErrInvalidHeader\n\t}\n\n\tctx := r.Context()\n\tlogger := log.FromContext(ctx).WithPrefix(\"http.auth\")\n\tbe := backend.FromContext(ctx)\n\n\tlogger.Debug(\"authorization auth header\", \"header\", header)\n\n\tparts := strings.SplitN(header, \" \", 2)\n\tif len(parts) != 2 {\n\t\treturn nil, errors.New(\"invalid authorization header\")\n\t}\n\n\tswitch strings.ToLower(parts[0]) {\n\tcase \"token\":\n\t\tuser, err := be.UserByAccessToken(ctx, parts[1])\n\t\tif err != nil {\n\t\t\tlogger.Error(\"failed to get user\", \"err\", err)\n\t\t\treturn nil, err\n\t\t}\n\n\t\treturn user, nil\n\tcase \"bearer\":\n\t\tclaims, err := parseJWT(ctx, parts[1])\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\t// Find the user\n\t\tparts := strings.SplitN(claims.Subject, \"#\", 2)\n\t\tif len(parts) != 2 {\n\t\t\tlogger.Error(\"invalid jwt subject\", \"subject\", claims.Subject)\n\t\t\treturn nil, errors.New(\"invalid jwt subject\")\n\t\t}\n\n\t\tuser, err := be.User(ctx, parts[0])\n\t\tif err != nil {\n\t\t\tlogger.Error(\"failed to get user\", \"err\", err)\n\t\t\treturn nil, err\n\t\t}\n\n\t\texpectedSubject := fmt.Sprintf(\"%s#%d\", user.Username(), user.ID())\n\t\tif expectedSubject != claims.Subject {\n\t\t\tlogger.Error(\"invalid jwt subject\", \"subject\", claims.Subject, \"expected\", expectedSubject)\n\t\t\treturn nil, errors.New(\"invalid jwt subject\")\n\t\t}\n\n\t\treturn user, nil\n\tdefault:\n\t\tusername, password, ok := r.BasicAuth()\n\t\tif !ok {\n\t\t\treturn nil, ErrInvalidHeader\n\t\t}\n\n\t\treturn parseUsernamePassword(ctx, username, password)\n\t}\n}\n\n// ErrInvalidToken is returned when a token is invalid.\nvar ErrInvalidToken = errors.New(\"invalid token\")\n\nfunc parseJWT(ctx context.Context, bearer string) (*jwt.RegisteredClaims, error) {\n\tcfg := config.FromContext(ctx)\n\tlogger := log.FromContext(ctx).WithPrefix(\"http.auth\")\n\tkp, err := config.KeyPair(cfg)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\trepo := proto.RepositoryFromContext(ctx)\n\tif repo == nil {\n\t\treturn nil, errors.New(\"missing repository\")\n\t}\n\n\ttoken, err := jwt.ParseWithClaims(bearer, &jwt.RegisteredClaims{}, func(t *jwt.Token) (interface{}, error) {\n\t\tif _, ok := t.Method.(*jwt.SigningMethodEd25519); !ok {\n\t\t\treturn nil, errors.New(\"invalid signing method\")\n\t\t}\n\n\t\treturn kp.CryptoPublicKey(), nil\n\t},\n\t\tjwt.WithIssuer(cfg.HTTP.PublicURL),\n\t\tjwt.WithIssuedAt(),\n\t\tjwt.WithAudience(repo.Name()),\n\t)\n\tif err != nil {\n\t\tlogger.Error(\"failed to parse jwt\", \"err\", err)\n\t\treturn nil, ErrInvalidToken\n\t}\n\n\tclaims, ok := token.Claims.(*jwt.RegisteredClaims)\n\tif !token.Valid || !ok {\n\t\treturn nil, ErrInvalidToken\n\t}\n\n\treturn claims, nil\n}\n"
  },
  {
    "path": "pkg/web/context.go",
    "content": "package web\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\n\t\"charm.land/log/v2\"\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/store\"\n)\n\n// NewContextHandler returns a new context middleware.\n// This middleware adds the config, backend, and logger to the request context.\nfunc NewContextHandler(ctx context.Context) func(http.Handler) http.Handler {\n\tcfg := config.FromContext(ctx)\n\tbe := backend.FromContext(ctx)\n\tlogger := log.FromContext(ctx).WithPrefix(\"http\")\n\tdbx := db.FromContext(ctx)\n\tdatastore := store.FromContext(ctx)\n\treturn func(next http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tctx := r.Context()\n\t\t\tctx = config.WithContext(ctx, cfg)\n\t\t\tctx = backend.WithContext(ctx, be)\n\t\t\tctx = log.WithContext(ctx, logger.With(\n\t\t\t\t\"method\", r.Method,\n\t\t\t\t\"path\", r.URL,\n\t\t\t\t\"addr\", r.RemoteAddr,\n\t\t\t))\n\t\t\tctx = db.WithContext(ctx, dbx)\n\t\t\tctx = store.WithContext(ctx, datastore)\n\t\t\tr = r.WithContext(ctx)\n\n\t\t\tnext.ServeHTTP(w, r)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/web/git.go",
    "content": "package web\n\nimport (\n\t\"bytes\"\n\t\"compress/gzip\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"charm.land/log/v2\"\n\tgitb \"github.com/charmbracelet/soft-serve/git\"\n\t\"github.com/charmbracelet/soft-serve/pkg/access\"\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/git\"\n\t\"github.com/charmbracelet/soft-serve/pkg/lfs\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/utils\"\n\t\"github.com/gorilla/mux\"\n\t\"github.com/prometheus/client_golang/prometheus\"\n\t\"github.com/prometheus/client_golang/prometheus/promauto\"\n)\n\n// GitRoute is a route for git services.\ntype GitRoute struct {\n\tmethod  []string\n\thandler http.HandlerFunc\n\tpath    string\n}\n\nvar _ http.Handler = GitRoute{}\n\n// ServeHTTP implements http.Handler.\nfunc (g GitRoute) ServeHTTP(w http.ResponseWriter, r *http.Request) {\n\tvar hasMethod bool\n\tfor _, m := range g.method {\n\t\tif m == r.Method {\n\t\t\thasMethod = true\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif !hasMethod {\n\t\trenderMethodNotAllowed(w, r)\n\t\treturn\n\t}\n\n\tg.handler(w, r)\n}\n\nvar (\n\t//nolint:revive\n\tgitHttpReceiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{\n\t\tNamespace: \"soft_serve\",\n\t\tSubsystem: \"http\",\n\t\tName:      \"git_receive_pack_total\",\n\t\tHelp:      \"The total number of git push requests\",\n\t}, []string{\"repo\"})\n\n\t//nolint:revive\n\tgitHttpUploadCounter = promauto.NewCounterVec(prometheus.CounterOpts{\n\t\tNamespace: \"soft_serve\",\n\t\tSubsystem: \"http\",\n\t\tName:      \"git_upload_pack_total\",\n\t\tHelp:      \"The total number of git fetch/pull requests\",\n\t}, []string{\"repo\", \"file\"})\n)\n\nfunc withParams(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tctx := r.Context()\n\t\tcfg := config.FromContext(ctx)\n\t\tvars := mux.Vars(r)\n\t\trepo := vars[\"repo\"]\n\n\t\t// Construct \"file\" param from path\n\t\tvars[\"file\"] = strings.TrimPrefix(r.URL.Path, \"/\"+repo+\"/\")\n\n\t\t// Set service type\n\t\tswitch {\n\t\tcase strings.HasSuffix(r.URL.Path, git.UploadPackService.String()):\n\t\t\tvars[\"service\"] = git.UploadPackService.String()\n\t\tcase strings.HasSuffix(r.URL.Path, git.ReceivePackService.String()):\n\t\t\tvars[\"service\"] = git.ReceivePackService.String()\n\t\t}\n\n\t\trepo = utils.SanitizeRepo(repo)\n\t\tvars[\"repo\"] = repo\n\t\tvars[\"dir\"] = filepath.Join(cfg.DataPath, \"repos\", repo+\".git\")\n\n\t\t// Add repo suffix (.git)\n\t\tr.URL.Path = fmt.Sprintf(\"%s.git/%s\", repo, vars[\"file\"])\n\t\tr = mux.SetURLVars(r, vars)\n\n\t\tnext.ServeHTTP(w, r)\n\t})\n}\n\n// GitController is a router for git services.\nfunc GitController(_ context.Context, r *mux.Router) {\n\tbasePrefix := \"/{repo:.*}\"\n\tfor _, route := range gitRoutes {\n\t\t// NOTE: withParam must always be the outermost wrapper, otherwise the\n\t\t// request vars will not be set.\n\t\tr.Handle(basePrefix+route.path, withParams(withAccess(route)))\n\t}\n\n\t// Handle go-get\n\tr.Handle(basePrefix, withParams(withAccess(http.HandlerFunc(GoGetHandler)))).Methods(http.MethodGet)\n}\n\nvar gitRoutes = []GitRoute{\n\t// Git services\n\t// These routes don't handle authentication/authorization.\n\t// This is handled through wrapping the handlers for each route.\n\t// See below (withAccess).\n\t{\n\t\tmethod:  []string{http.MethodPost},\n\t\thandler: serviceRpc,\n\t\tpath:    \"/{service:(?:git-upload-archive|git-upload-pack|git-receive-pack)$}\",\n\t},\n\t{\n\t\tmethod:  []string{http.MethodGet},\n\t\thandler: getInfoRefs,\n\t\tpath:    \"/info/refs\",\n\t},\n\t{\n\t\tmethod:  []string{http.MethodGet},\n\t\thandler: getTextFile,\n\t\tpath:    \"/{_:(?:HEAD|objects/info/alternates|objects/info/http-alternates|objects/info/[^/]*)$}\",\n\t},\n\t{\n\t\tmethod:  []string{http.MethodGet},\n\t\thandler: getInfoPacks,\n\t\tpath:    \"/objects/info/packs\",\n\t},\n\t{\n\t\tmethod:  []string{http.MethodGet},\n\t\thandler: getLooseObject,\n\t\tpath:    \"/objects/{_:[0-9a-f]{2}/[0-9a-f]{38}$}\",\n\t},\n\t{\n\t\tmethod:  []string{http.MethodGet},\n\t\thandler: getPackFile,\n\t\tpath:    \"/objects/pack/{_:pack-[0-9a-f]{40}\\\\.pack$}\",\n\t},\n\t{\n\t\tmethod:  []string{http.MethodGet},\n\t\thandler: getIdxFile,\n\t\tpath:    \"/objects/pack/{_:pack-[0-9a-f]{40}\\\\.idx$}\",\n\t},\n\t// Git LFS\n\t{\n\t\tmethod:  []string{http.MethodPost},\n\t\thandler: serviceLfsBatch,\n\t\tpath:    \"/info/lfs/objects/batch\",\n\t},\n\t{\n\t\t// Git LFS basic object handler\n\t\tmethod:  []string{http.MethodGet, http.MethodPut},\n\t\thandler: serviceLfsBasic,\n\t\tpath:    \"/info/lfs/objects/basic/{oid:[0-9a-f]{64}$}\",\n\t},\n\t{\n\t\tmethod:  []string{http.MethodPost},\n\t\thandler: serviceLfsBasicVerify,\n\t\tpath:    \"/info/lfs/objects/basic/verify\",\n\t},\n\t// Git LFS locks\n\t{\n\t\tmethod:  []string{http.MethodPost, http.MethodGet},\n\t\thandler: serviceLfsLocks,\n\t\tpath:    \"/info/lfs/locks\",\n\t},\n\t{\n\t\tmethod:  []string{http.MethodPost},\n\t\thandler: serviceLfsLocksVerify,\n\t\tpath:    \"/info/lfs/locks/verify\",\n\t},\n\t{\n\t\tmethod:  []string{http.MethodPost},\n\t\thandler: serviceLfsLocksDelete,\n\t\tpath:    \"/info/lfs/locks/{lock_id:[0-9]+}/unlock\",\n\t},\n}\n\nfunc askCredentials(w http.ResponseWriter, _ *http.Request) {\n\tw.Header().Set(\"WWW-Authenticate\", `Basic realm=\"Git\" charset=\"UTF-8\", Token, Bearer`)\n\tw.Header().Set(\"LFS-Authenticate\", `Basic realm=\"Git LFS\" charset=\"UTF-8\", Token, Bearer`)\n}\n\n// withAccess handles auth.\nfunc withAccess(next http.Handler) http.HandlerFunc {\n\treturn func(w http.ResponseWriter, r *http.Request) {\n\t\tctx := r.Context()\n\t\tcfg := config.FromContext(ctx)\n\t\tlogger := log.FromContext(ctx)\n\t\tbe := backend.FromContext(ctx)\n\n\t\t// Store repository in context\n\t\t// We're not checking for errors here because we want to allow\n\t\t// repo creation on the fly.\n\t\trepoName := mux.Vars(r)[\"repo\"]\n\t\trepo, _ := be.Repository(ctx, repoName)\n\t\tctx = proto.WithRepositoryContext(ctx, repo)\n\t\tr = r.WithContext(ctx)\n\n\t\tuser, err := authenticate(r)\n\t\tif err != nil {\n\t\t\tswitch {\n\t\t\tcase errors.Is(err, ErrInvalidToken):\n\t\t\tcase errors.Is(err, proto.ErrUserNotFound):\n\t\t\tdefault:\n\t\t\t\tlogger.Error(\"failed to authenticate\", \"err\", err)\n\t\t\t}\n\t\t}\n\n\t\tif user == nil && !be.AllowKeyless(ctx) {\n\t\t\taskCredentials(w, r)\n\t\t\trenderUnauthorized(w, r)\n\t\t\treturn\n\t\t}\n\n\t\t// Store user in context\n\t\tctx = proto.WithUserContext(ctx, user)\n\t\tr = r.WithContext(ctx)\n\n\t\tif user != nil {\n\t\t\tlogger.Debug(\"authenticated\", \"username\", user.Username())\n\t\t}\n\n\t\tservice := git.Service(mux.Vars(r)[\"service\"])\n\t\tif service == \"\" {\n\t\t\t// Get service from request params\n\t\t\tservice = getServiceType(r)\n\t\t}\n\n\t\taccessLevel := be.AccessLevelForUser(ctx, repoName, user)\n\t\tctx = access.WithContext(ctx, accessLevel)\n\t\tr = r.WithContext(ctx)\n\n\t\tfile := mux.Vars(r)[\"file\"]\n\n\t\t// We only allow these services to proceed any other services should return 403\n\t\t// - git-upload-pack\n\t\t// - git-receive-pack\n\t\t// - git-lfs\n\t\tswitch {\n\t\tcase service == git.ReceivePackService:\n\t\t\tif accessLevel < access.ReadWriteAccess {\n\t\t\t\taskCredentials(w, r)\n\t\t\t\trenderUnauthorized(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Create the repo if it doesn't exist.\n\t\t\tif repo == nil {\n\t\t\t\trepo, err = be.CreateRepository(ctx, repoName, user, proto.RepositoryOptions{})\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.Error(\"failed to create repository\", \"repo\", repoName, \"err\", err)\n\t\t\t\t\trenderInternalServerError(w, r)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tctx = proto.WithRepositoryContext(ctx, repo)\n\t\t\t\tr = r.WithContext(ctx)\n\t\t\t}\n\n\t\t\tfallthrough\n\t\tcase service == git.UploadPackService || service == git.UploadArchiveService:\n\t\t\tif repo == nil {\n\t\t\t\t// If the repo doesn't exist, return 404\n\t\t\t\trenderNotFound(w, r)\n\t\t\t\treturn\n\t\t\t} else if errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword) {\n\t\t\t\t// return 403 when bad credentials are provided\n\t\t\t\trenderForbidden(w, r)\n\t\t\t\treturn\n\t\t\t} else if accessLevel < access.ReadOnlyAccess {\n\t\t\t\taskCredentials(w, r)\n\t\t\t\trenderUnauthorized(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\tcase strings.HasPrefix(file, \"info/lfs\"):\n\t\t\tif !cfg.LFS.Enabled {\n\t\t\t\tlogger.Debug(\"LFS is not enabled, skipping\")\n\t\t\t\trenderNotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tswitch {\n\t\t\tcase strings.HasPrefix(file, \"info/lfs/locks\"):\n\t\t\t\tswitch {\n\t\t\t\tcase strings.HasSuffix(file, \"lfs/locks\"), strings.HasSuffix(file, \"/unlock\") && r.Method == http.MethodPost:\n\t\t\t\t\t// Create lock, list locks, and delete lock require write access\n\t\t\t\t\tfallthrough\n\t\t\t\tcase strings.HasSuffix(file, \"lfs/locks/verify\"):\n\t\t\t\t\t// Locks verify requires write access\n\t\t\t\t\t// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md#unauthorized-response-2\n\t\t\t\t\tif accessLevel < access.ReadWriteAccess {\n\t\t\t\t\t\trenderJSON(w, http.StatusForbidden, lfs.ErrorResponse{\n\t\t\t\t\t\t\tMessage: \"write access required\",\n\t\t\t\t\t\t})\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\tcase strings.HasPrefix(file, \"info/lfs/objects/basic\"):\n\t\t\t\tswitch r.Method {\n\t\t\t\tcase http.MethodPut:\n\t\t\t\t\t// Basic upload\n\t\t\t\t\tif accessLevel < access.ReadWriteAccess {\n\t\t\t\t\t\trenderJSON(w, http.StatusForbidden, lfs.ErrorResponse{\n\t\t\t\t\t\t\tMessage: \"write access required\",\n\t\t\t\t\t\t})\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\tcase http.MethodGet:\n\t\t\t\t\t// Basic download\n\t\t\t\tcase http.MethodPost:\n\t\t\t\t\t// Basic verify\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif accessLevel < access.ReadOnlyAccess {\n\t\t\t\tif repo == nil {\n\t\t\t\t\trenderJSON(w, http.StatusNotFound, lfs.ErrorResponse{\n\t\t\t\t\t\tMessage: \"repository not found\",\n\t\t\t\t\t})\n\t\t\t\t} else if errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword) {\n\t\t\t\t\trenderJSON(w, http.StatusForbidden, lfs.ErrorResponse{\n\t\t\t\t\t\tMessage: \"bad credentials\",\n\t\t\t\t\t})\n\t\t\t\t} else {\n\t\t\t\t\taskCredentials(w, r)\n\t\t\t\t\trenderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{\n\t\t\t\t\t\tMessage: \"credentials needed\",\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tswitch {\n\t\tcase r.URL.Query().Get(\"go-get\") == \"1\" && accessLevel >= access.ReadOnlyAccess:\n\t\t\t// Allow go-get requests to passthrough.\n\t\t\tbreak\n\t\tcase errors.Is(err, ErrInvalidToken), errors.Is(err, ErrInvalidPassword):\n\t\t\t// return 403 when bad credentials are provided\n\t\t\trenderForbidden(w, r)\n\t\t\treturn\n\t\tcase repo == nil, accessLevel < access.ReadOnlyAccess:\n\t\t\t// Don't hint that the repo exists if the user doesn't have access\n\t\t\trenderNotFound(w, r)\n\t\t\treturn\n\t\t}\n\n\t\tnext.ServeHTTP(w, r)\n\t}\n}\n\n//nolint:revive\nfunc serviceRpc(w http.ResponseWriter, r *http.Request) {\n\tctx := r.Context()\n\tcfg := config.FromContext(ctx)\n\tlogger := log.FromContext(ctx)\n\tservice, dir, repoName := git.Service(mux.Vars(r)[\"service\"]), mux.Vars(r)[\"dir\"], mux.Vars(r)[\"repo\"]\n\n\tif !isSmart(r, service) {\n\t\trenderForbidden(w, r)\n\t\treturn\n\t}\n\n\tif service == git.ReceivePackService {\n\t\tgitHttpReceiveCounter.WithLabelValues(repoName)\n\t}\n\n\tw.Header().Set(\"Content-Type\", fmt.Sprintf(\"application/x-%s-result\", service))\n\tw.Header().Set(\"Connection\", \"Keep-Alive\")\n\tw.Header().Set(\"Transfer-Encoding\", \"chunked\")\n\tw.Header().Set(\"X-Content-Type-Options\", \"nosniff\")\n\tw.WriteHeader(http.StatusOK)\n\n\tversion := r.Header.Get(\"Git-Protocol\")\n\n\tvar stdout bytes.Buffer\n\tcmd := git.ServiceCommand{\n\t\tStdout: &stdout,\n\t\tDir:    dir,\n\t}\n\n\tswitch service {\n\tcase git.UploadPackService, git.ReceivePackService:\n\t\tcmd.Args = append(cmd.Args, \"--stateless-rpc\")\n\t}\n\n\tuser := proto.UserFromContext(ctx)\n\tcmd.Env = cfg.Environ()\n\tcmd.Env = append(cmd.Env, []string{\n\t\t\"SOFT_SERVE_REPO_NAME=\" + repoName,\n\t\t\"SOFT_SERVE_REPO_PATH=\" + dir,\n\t\t\"SOFT_SERVE_LOG_PATH=\" + filepath.Join(cfg.DataPath, \"log\", \"hooks.log\"),\n\t}...)\n\tif user != nil {\n\t\tcmd.Env = append(cmd.Env, []string{\n\t\t\t\"SOFT_SERVE_USERNAME=\" + user.Username(),\n\t\t}...)\n\t}\n\tif len(version) != 0 {\n\t\tcmd.Env = append(cmd.Env, []string{\n\t\t\tfmt.Sprintf(\"GIT_PROTOCOL=%s\", version),\n\t\t}...)\n\t}\n\n\tvar (\n\t\terr    error\n\t\treader io.ReadCloser\n\t)\n\n\t// Handle gzip encoding\n\treader = r.Body\n\tswitch r.Header.Get(\"Content-Encoding\") {\n\tcase \"gzip\":\n\t\treader, err = gzip.NewReader(reader)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"failed to create gzip reader: %v\", err)\n\t\t\trenderInternalServerError(w, r)\n\t\t\treturn\n\t\t}\n\t\tdefer reader.Close() //nolint: errcheck\n\t}\n\n\tcmd.Stdin = reader\n\tcmd.Stdout = &flushResponseWriter{w}\n\n\tif err := service.Handler(ctx, cmd); err != nil {\n\t\tlogger.Errorf(\"failed to handle service: %v\", err)\n\t\treturn\n\t}\n\n\tif service == git.ReceivePackService {\n\t\tif err := git.EnsureDefaultBranch(ctx, cmd.Dir); err != nil {\n\t\t\tlogger.Errorf(\"failed to ensure default branch: %s\", err)\n\t\t}\n\t}\n}\n\n// Handle buffered output\n// Useful when using proxies\ntype flushResponseWriter struct {\n\thttp.ResponseWriter\n}\n\nfunc (f *flushResponseWriter) ReadFrom(r io.Reader) (int64, error) {\n\tflusher := http.NewResponseController(f.ResponseWriter)\n\n\tvar n int64\n\tp := make([]byte, 1024)\n\tfor {\n\t\tnRead, err := r.Read(p)\n\t\tif err == io.EOF {\n\t\t\tbreak\n\t\t}\n\t\tnWrite, err := f.ResponseWriter.Write(p[:nRead])\n\t\tif err != nil {\n\t\t\treturn n, err\n\t\t}\n\t\tif nRead != nWrite {\n\t\t\treturn n, err\n\t\t}\n\t\tn += int64(nRead)\n\t\t// ResponseWriter must support http.Flusher to handle buffered output.\n\t\tif err := flusher.Flush(); err != nil {\n\t\t\treturn n, fmt.Errorf(\"%w: error while flush\", err)\n\t\t}\n\t}\n\n\treturn n, nil\n}\n\nfunc getInfoRefs(w http.ResponseWriter, r *http.Request) {\n\tctx := r.Context()\n\tcfg := config.FromContext(ctx)\n\tdir, repoName, file := mux.Vars(r)[\"dir\"], mux.Vars(r)[\"repo\"], mux.Vars(r)[\"file\"]\n\tservice := getServiceType(r)\n\tprotocol := r.Header.Get(\"Git-Protocol\")\n\n\tgitHttpUploadCounter.WithLabelValues(repoName, file).Inc()\n\n\tif service != \"\" && (service == git.UploadPackService || service == git.ReceivePackService) {\n\t\t// Smart HTTP\n\t\tvar refs bytes.Buffer\n\t\tcmd := git.ServiceCommand{\n\t\t\tStdout: &refs,\n\t\t\tDir:    dir,\n\t\t\tArgs:   []string{\"--stateless-rpc\", \"--advertise-refs\"},\n\t\t}\n\n\t\tuser := proto.UserFromContext(ctx)\n\t\tcmd.Env = cfg.Environ()\n\t\tcmd.Env = append(cmd.Env, []string{\n\t\t\t\"SOFT_SERVE_REPO_NAME=\" + repoName,\n\t\t\t\"SOFT_SERVE_REPO_PATH=\" + dir,\n\t\t\t\"SOFT_SERVE_LOG_PATH=\" + filepath.Join(cfg.DataPath, \"log\", \"hooks.log\"),\n\t\t}...)\n\t\tif user != nil {\n\t\t\tcmd.Env = append(cmd.Env, []string{\n\t\t\t\t\"SOFT_SERVE_USERNAME=\" + user.Username(),\n\t\t\t}...)\n\t\t}\n\t\tif len(protocol) != 0 {\n\t\t\tcmd.Env = append(cmd.Env, fmt.Sprintf(\"GIT_PROTOCOL=%s\", protocol))\n\t\t}\n\n\t\tvar version int\n\t\tfor _, p := range strings.Split(protocol, \":\") {\n\t\t\tif strings.HasPrefix(p, \"version=\") {\n\t\t\t\tif v, _ := strconv.Atoi(p[8:]); v > version {\n\t\t\t\t\tversion = v\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif err := service.Handler(ctx, cmd); err != nil {\n\t\t\trenderNotFound(w, r)\n\t\t\treturn\n\t\t}\n\n\t\thdrNocache(w)\n\t\tw.Header().Set(\"Content-Type\", fmt.Sprintf(\"application/x-%s-advertisement\", service))\n\t\tw.WriteHeader(http.StatusOK)\n\t\tif version < 2 {\n\t\t\tgit.WritePktline(w, \"# service=\"+service.String()) //nolint: errcheck\n\t\t}\n\t\tw.Write(refs.Bytes()) //nolint: errcheck\n\t} else {\n\t\t// Dumb HTTP\n\t\tupdateServerInfo(ctx, dir) //nolint: errcheck\n\t\thdrNocache(w)\n\t\tsendFile(\"text/plain; charset=utf-8\", w, r)\n\t}\n}\n\nfunc getInfoPacks(w http.ResponseWriter, r *http.Request) {\n\thdrCacheForever(w)\n\tsendFile(\"text/plain; charset=utf-8\", w, r)\n}\n\nfunc getLooseObject(w http.ResponseWriter, r *http.Request) {\n\thdrCacheForever(w)\n\tsendFile(\"application/x-git-loose-object\", w, r)\n}\n\nfunc getPackFile(w http.ResponseWriter, r *http.Request) {\n\thdrCacheForever(w)\n\tsendFile(\"application/x-git-packed-objects\", w, r)\n}\n\nfunc getIdxFile(w http.ResponseWriter, r *http.Request) {\n\thdrCacheForever(w)\n\tsendFile(\"application/x-git-packed-objects-toc\", w, r)\n}\n\nfunc getTextFile(w http.ResponseWriter, r *http.Request) {\n\thdrNocache(w)\n\tsendFile(\"text/plain\", w, r)\n}\n\nfunc sendFile(contentType string, w http.ResponseWriter, r *http.Request) {\n\tdir, file := mux.Vars(r)[\"dir\"], mux.Vars(r)[\"file\"]\n\treqFile := filepath.Join(dir, file)\n\n\tf, err := os.Stat(reqFile)\n\tif os.IsNotExist(err) {\n\t\trenderNotFound(w, r)\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", contentType)\n\tw.Header().Set(\"Content-Length\", fmt.Sprintf(\"%d\", f.Size()))\n\tw.Header().Set(\"Last-Modified\", f.ModTime().Format(http.TimeFormat))\n\thttp.ServeFile(w, r, reqFile)\n}\n\nfunc getServiceType(r *http.Request) git.Service {\n\tservice := r.FormValue(\"service\")\n\tif !strings.HasPrefix(service, \"git-\") {\n\t\treturn \"\"\n\t}\n\n\treturn git.Service(service)\n}\n\nfunc isSmart(r *http.Request, service git.Service) bool {\n\tcontentType := r.Header.Get(\"Content-Type\")\n\treturn strings.HasPrefix(contentType, fmt.Sprintf(\"application/x-%s-request\", service))\n}\n\nfunc updateServerInfo(ctx context.Context, dir string) error {\n\treturn gitb.UpdateServerInfo(ctx, dir)\n}\n\n// HTTP error response handling functions\n\nfunc renderBadRequest(w http.ResponseWriter, r *http.Request) {\n\trenderStatus(http.StatusBadRequest)(w, r)\n}\n\nfunc renderMethodNotAllowed(w http.ResponseWriter, r *http.Request) {\n\tif r.Proto == \"HTTP/1.1\" {\n\t\trenderStatus(http.StatusMethodNotAllowed)(w, r)\n\t} else {\n\t\trenderBadRequest(w, r)\n\t}\n}\n\nfunc renderNotFound(w http.ResponseWriter, r *http.Request) {\n\trenderStatus(http.StatusNotFound)(w, r)\n}\n\nfunc renderUnauthorized(w http.ResponseWriter, r *http.Request) {\n\trenderStatus(http.StatusUnauthorized)(w, r)\n}\n\nfunc renderForbidden(w http.ResponseWriter, r *http.Request) {\n\trenderStatus(http.StatusForbidden)(w, r)\n}\n\nfunc renderInternalServerError(w http.ResponseWriter, r *http.Request) {\n\trenderStatus(http.StatusInternalServerError)(w, r)\n}\n\n// Header writing functions\n\nfunc hdrNocache(w http.ResponseWriter) {\n\tw.Header().Set(\"Expires\", \"Fri, 01 Jan 1980 00:00:00 GMT\")\n\tw.Header().Set(\"Pragma\", \"no-cache\")\n\tw.Header().Set(\"Cache-Control\", \"no-cache, max-age=0, must-revalidate\")\n}\n\nfunc hdrCacheForever(w http.ResponseWriter) {\n\tnow := time.Now().Unix()\n\texpires := now + 31536000\n\tw.Header().Set(\"Date\", fmt.Sprintf(\"%d\", now))\n\tw.Header().Set(\"Expires\", fmt.Sprintf(\"%d\", expires))\n\tw.Header().Set(\"Cache-Control\", \"public, max-age=31536000\")\n}\n"
  },
  {
    "path": "pkg/web/git_lfs.go",
    "content": "package web\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"path\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"charm.land/log/v2\"\n\t\"github.com/charmbracelet/soft-serve/pkg/access\"\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db/models\"\n\t\"github.com/charmbracelet/soft-serve/pkg/lfs\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/storage\"\n\t\"github.com/charmbracelet/soft-serve/pkg/store\"\n\t\"github.com/gorilla/mux\"\n)\n\n// serviceLfsBatch handles a Git LFS batch requests.\n// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md\n// TODO: support refname\n// POST: /<repo>.git/info/lfs/objects/batch\nfunc serviceLfsBatch(w http.ResponseWriter, r *http.Request) {\n\tctx := r.Context()\n\tlogger := log.FromContext(ctx).WithPrefix(\"http.lfs\")\n\n\tif !isLfs(r) {\n\t\tlogger.Errorf(\"invalid content type: %s\", r.Header.Get(\"Content-Type\"))\n\t\trenderNotAcceptable(w)\n\t\treturn\n\t}\n\n\tvar batchRequest lfs.BatchRequest\n\tdefer r.Body.Close() //nolint: errcheck\n\tif err := json.NewDecoder(r.Body).Decode(&batchRequest); err != nil {\n\t\tlogger.Errorf(\"error decoding json: %s\", err)\n\t\trenderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{\n\t\t\tMessage: \"validation error in request: \" + err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\t// We only accept basic transfers for now\n\t// Default to basic if no transfer is specified\n\tif len(batchRequest.Transfers) > 0 {\n\t\tvar isBasic bool\n\t\tfor _, t := range batchRequest.Transfers {\n\t\t\tif t == lfs.TransferBasic {\n\t\t\t\tisBasic = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif !isBasic {\n\t\t\trenderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{\n\t\t\t\tMessage: \"unsupported transfer\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t}\n\n\tif len(batchRequest.Objects) == 0 {\n\t\trenderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{\n\t\t\tMessage: \"no objects found\",\n\t\t})\n\t\treturn\n\t}\n\n\tname := mux.Vars(r)[\"repo\"]\n\trepo := proto.RepositoryFromContext(ctx)\n\tif repo == nil {\n\t\trenderJSON(w, http.StatusNotFound, lfs.ErrorResponse{\n\t\t\tMessage: \"repository not found\",\n\t\t})\n\t\treturn\n\t}\n\n\tcfg := config.FromContext(ctx)\n\tdbx := db.FromContext(ctx)\n\tdatastore := store.FromContext(ctx)\n\t// TODO: support S3 storage\n\trepoID := strconv.FormatInt(repo.ID(), 10)\n\tstrg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, \"lfs\", repoID))\n\n\tbaseHref := fmt.Sprintf(\"%s/%s/info/lfs/objects/basic\", cfg.HTTP.PublicURL, name+\".git\")\n\n\tvar batchResponse lfs.BatchResponse\n\tbatchResponse.Transfer = lfs.TransferBasic\n\tbatchResponse.HashAlgo = lfs.HashAlgorithmSHA256\n\n\tobjects := make([]*lfs.ObjectResponse, 0, len(batchRequest.Objects))\n\t// XXX: We don't support objects TTL for now, probably implement that with\n\t// S3 using object \"expires_at\" & \"expires_in\"\n\tswitch batchRequest.Operation {\n\tcase lfs.OperationDownload:\n\t\tfor _, o := range batchRequest.Objects {\n\t\t\texist, err := strg.Exists(path.Join(\"objects\", o.RelativePath()))\n\t\t\tif err != nil && !errors.Is(err, fs.ErrNotExist) {\n\t\t\t\tlogger.Error(\"error getting object stat\", \"oid\", o.Oid, \"repo\", name, \"err\", err)\n\t\t\t\trenderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{\n\t\t\t\t\tMessage: \"internal server error\",\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tobj, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), o.Oid)\n\t\t\tif err != nil && !errors.Is(err, db.ErrRecordNotFound) {\n\t\t\t\tlogger.Error(\"error getting object from database\", \"oid\", o.Oid, \"repo\", name, \"err\", err)\n\t\t\t\trenderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{\n\t\t\t\t\tMessage: \"internal server error\",\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif !exist {\n\t\t\t\tobjects = append(objects, &lfs.ObjectResponse{\n\t\t\t\t\tPointer: o,\n\t\t\t\t\tError: &lfs.ObjectError{\n\t\t\t\t\t\tCode:    http.StatusNotFound,\n\t\t\t\t\t\tMessage: \"object not found\",\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t} else if obj.Size != o.Size {\n\t\t\t\tobjects = append(objects, &lfs.ObjectResponse{\n\t\t\t\t\tPointer: o,\n\t\t\t\t\tError: &lfs.ObjectError{\n\t\t\t\t\t\tCode:    http.StatusUnprocessableEntity,\n\t\t\t\t\t\tMessage: \"size mismatch\",\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t} else if o.IsValid() {\n\t\t\t\tdownload := &lfs.Link{\n\t\t\t\t\tHref: fmt.Sprintf(\"%s/%s\", baseHref, o.Oid),\n\t\t\t\t}\n\t\t\t\tif auth := r.Header.Get(\"Authorization\"); auth != \"\" {\n\t\t\t\t\tdownload.Header = map[string]string{\n\t\t\t\t\t\t\"Authorization\": auth,\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tobjects = append(objects, &lfs.ObjectResponse{\n\t\t\t\t\tPointer: o,\n\t\t\t\t\tActions: map[string]*lfs.Link{\n\t\t\t\t\t\tlfs.ActionDownload: download,\n\t\t\t\t\t},\n\t\t\t\t})\n\n\t\t\t\t// If the object doesn't exist in the database, create it\n\t\t\t\tif exist && obj.ID == 0 {\n\t\t\t\t\tif err := datastore.CreateLFSObject(ctx, dbx, repo.ID(), o.Oid, o.Size); err != nil {\n\t\t\t\t\t\tlogger.Error(\"error creating object in datastore\", \"oid\", o.Oid, \"repo\", name, \"err\", err)\n\t\t\t\t\t\trenderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{\n\t\t\t\t\t\t\tMessage: \"internal server error\",\n\t\t\t\t\t\t})\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tlogger.Error(\"invalid object\", \"oid\", o.Oid, \"repo\", name)\n\t\t\t\tobjects = append(objects, &lfs.ObjectResponse{\n\t\t\t\t\tPointer: o,\n\t\t\t\t\tError: &lfs.ObjectError{\n\t\t\t\t\t\tCode:    http.StatusUnprocessableEntity,\n\t\t\t\t\t\tMessage: \"invalid object\",\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\tcase lfs.OperationUpload:\n\t\t// Check authorization\n\t\taccessLevel := access.FromContext(ctx)\n\t\tif accessLevel < access.ReadWriteAccess {\n\t\t\taskCredentials(w, r)\n\t\t\trenderJSON(w, http.StatusForbidden, lfs.ErrorResponse{\n\t\t\t\tMessage: \"write access required\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\t// Object upload logic happens in the \"basic\" API route\n\t\tfor _, o := range batchRequest.Objects {\n\t\t\tif !o.IsValid() {\n\t\t\t\tobjects = append(objects, &lfs.ObjectResponse{\n\t\t\t\t\tPointer: o,\n\t\t\t\t\tError: &lfs.ObjectError{\n\t\t\t\t\t\tCode:    http.StatusUnprocessableEntity,\n\t\t\t\t\t\tMessage: \"invalid object\",\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tupload := &lfs.Link{\n\t\t\t\t\tHref: fmt.Sprintf(\"%s/%s\", baseHref, o.Oid),\n\t\t\t\t\tHeader: map[string]string{\n\t\t\t\t\t\t// NOTE: git-lfs v2.5.0 sets the Content-Type based on the uploaded file.\n\t\t\t\t\t\t// This ensures that the client always uses the designated value for the header.\n\t\t\t\t\t\t\"Content-Type\": \"application/octet-stream\",\n\t\t\t\t\t},\n\t\t\t\t}\n\t\t\t\tverify := &lfs.Link{\n\t\t\t\t\tHref: fmt.Sprintf(\"%s/verify\", baseHref),\n\t\t\t\t}\n\t\t\t\tif auth := r.Header.Get(\"Authorization\"); auth != \"\" {\n\t\t\t\t\tupload.Header[\"Authorization\"] = auth\n\t\t\t\t\tverify.Header = map[string]string{\n\t\t\t\t\t\t\"Authorization\": auth,\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tobjects = append(objects, &lfs.ObjectResponse{\n\t\t\t\t\tPointer: o,\n\t\t\t\t\tActions: map[string]*lfs.Link{\n\t\t\t\t\t\tlfs.ActionUpload: upload,\n\t\t\t\t\t\t// Verify uploaded objects\n\t\t\t\t\t\t// https://github.com/git-lfs/git-lfs/blob/main/docs/api/basic-transfers.md#verification\n\t\t\t\t\t\tlfs.ActionVerify: verify,\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\tdefault:\n\t\trenderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{\n\t\t\tMessage: \"unsupported operation\",\n\t\t})\n\t\treturn\n\t}\n\n\tbatchResponse.Objects = objects\n\trenderJSON(w, http.StatusOK, batchResponse)\n}\n\n// serviceLfsBasic implements Git LFS basic transfer API\n// https://github.com/git-lfs/git-lfs/blob/main/docs/api/basic-transfers.md\nfunc serviceLfsBasic(w http.ResponseWriter, r *http.Request) {\n\tswitch r.Method {\n\tcase http.MethodGet:\n\t\tserviceLfsBasicDownload(w, r)\n\tcase http.MethodPut:\n\t\tserviceLfsBasicUpload(w, r)\n\t}\n}\n\n// GET: /<repo>.git/info/lfs/objects/basic/<oid>\nfunc serviceLfsBasicDownload(w http.ResponseWriter, r *http.Request) {\n\tctx := r.Context()\n\toid := mux.Vars(r)[\"oid\"]\n\trepo := proto.RepositoryFromContext(ctx)\n\tcfg := config.FromContext(ctx)\n\tlogger := log.FromContext(ctx).WithPrefix(\"http.lfs-basic\")\n\tdatastore := store.FromContext(ctx)\n\tdbx := db.FromContext(ctx)\n\trepoID := strconv.FormatInt(repo.ID(), 10)\n\tstrg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, \"lfs\", repoID))\n\n\tobj, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), oid)\n\tif err != nil && !errors.Is(err, db.ErrRecordNotFound) {\n\t\tlogger.Error(\"error getting object from database\", \"oid\", oid, \"repo\", repo.Name(), \"err\", err)\n\t\trenderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{\n\t\t\tMessage: \"internal server error\",\n\t\t})\n\t\treturn\n\t}\n\n\tpointer := lfs.Pointer{Oid: oid}\n\tf, err := strg.Open(path.Join(\"objects\", pointer.RelativePath()))\n\tif err != nil {\n\t\tlogger.Error(\"error opening object\", \"oid\", oid, \"err\", err)\n\t\trenderJSON(w, http.StatusNotFound, lfs.ErrorResponse{\n\t\t\tMessage: \"object not found\",\n\t\t})\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/octet-stream\")\n\tw.Header().Set(\"Content-Length\", strconv.FormatInt(obj.Size, 10))\n\tdefer f.Close() //nolint: errcheck\n\tif _, err := io.Copy(w, f); err != nil {\n\t\tlogger.Error(\"error copying object to response\", \"oid\", oid, \"err\", err)\n\t\trenderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{\n\t\t\tMessage: \"internal server error\",\n\t\t})\n\t\treturn\n\t}\n}\n\n// PUT: /<repo>.git/info/lfs/objects/basic/<oid>\nfunc serviceLfsBasicUpload(w http.ResponseWriter, r *http.Request) {\n\tif !isBinary(r) {\n\t\trenderJSON(w, http.StatusUnsupportedMediaType, lfs.ErrorResponse{\n\t\t\tMessage: \"invalid content type\",\n\t\t})\n\t\treturn\n\t}\n\n\tctx := r.Context()\n\toid := mux.Vars(r)[\"oid\"]\n\tcfg := config.FromContext(ctx)\n\tbe := backend.FromContext(ctx)\n\tdbx := db.FromContext(ctx)\n\tdatastore := store.FromContext(ctx)\n\tlogger := log.FromContext(ctx).WithPrefix(\"http.lfs-basic\")\n\trepo := proto.RepositoryFromContext(ctx)\n\trepoID := strconv.FormatInt(repo.ID(), 10)\n\tstrg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, \"lfs\", repoID))\n\tname := mux.Vars(r)[\"repo\"]\n\n\tdefer r.Body.Close() //nolint: errcheck\n\trepo, err := be.Repository(ctx, name)\n\tif err != nil {\n\t\trenderJSON(w, http.StatusNotFound, lfs.ErrorResponse{\n\t\t\tMessage: \"repository not found\",\n\t\t})\n\t\treturn\n\t}\n\n\t// NOTE: Git LFS client will retry uploading the same object if there was a\n\t// partial error, so we need to skip existing objects.\n\tif _, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), oid); err == nil {\n\t\t// Object exists, skip request\n\t\tio.Copy(io.Discard, r.Body) //nolint: errcheck\n\t\trenderStatus(http.StatusOK)(w, nil)\n\t\treturn\n\t} else if !errors.Is(err, db.ErrRecordNotFound) {\n\t\tlogger.Error(\"error getting object\", \"oid\", oid, \"err\", err)\n\t\trenderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{\n\t\t\tMessage: \"internal server error\",\n\t\t})\n\t\treturn\n\t}\n\n\tpointer := lfs.Pointer{Oid: oid}\n\tif _, err := strg.Put(path.Join(\"objects\", pointer.RelativePath()), r.Body); err != nil {\n\t\tlogger.Error(\"error writing object\", \"oid\", oid, \"err\", err)\n\t\trenderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{\n\t\t\tMessage: \"internal server error\",\n\t\t})\n\t\treturn\n\t}\n\n\tsize, err := strconv.ParseInt(r.Header.Get(\"Content-Length\"), 10, 64)\n\tif err != nil {\n\t\tlogger.Error(\"error parsing content length\", \"err\", err)\n\t\trenderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{\n\t\t\tMessage: \"invalid content length\",\n\t\t})\n\t\treturn\n\t}\n\n\tif err := datastore.CreateLFSObject(ctx, dbx, repo.ID(), oid, size); err != nil {\n\t\tlogger.Error(\"error creating object\", \"oid\", oid, \"err\", err)\n\t\trenderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{\n\t\t\tMessage: \"internal server error\",\n\t\t})\n\t\treturn\n\t}\n\n\trenderStatus(http.StatusOK)(w, nil)\n}\n\n// POST: /<repo>.git/info/lfs/objects/basic/verify\nfunc serviceLfsBasicVerify(w http.ResponseWriter, r *http.Request) {\n\tif !isLfs(r) {\n\t\trenderNotAcceptable(w)\n\t\treturn\n\t}\n\n\tvar pointer lfs.Pointer\n\tctx := r.Context()\n\tlogger := log.FromContext(ctx).WithPrefix(\"http.lfs-basic\")\n\trepo := proto.RepositoryFromContext(ctx)\n\tif repo == nil {\n\t\tlogger.Error(\"error getting repository from context\")\n\t\trenderJSON(w, http.StatusNotFound, lfs.ErrorResponse{\n\t\t\tMessage: \"repository not found\",\n\t\t})\n\t\treturn\n\t}\n\n\tdefer r.Body.Close() //nolint: errcheck\n\tif err := json.NewDecoder(r.Body).Decode(&pointer); err != nil {\n\t\tlogger.Error(\"error decoding json\", \"err\", err)\n\t\trenderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{\n\t\t\tMessage: \"invalid request: \" + err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\tcfg := config.FromContext(ctx)\n\tdbx := db.FromContext(ctx)\n\tdatastore := store.FromContext(ctx)\n\trepoID := strconv.FormatInt(repo.ID(), 10)\n\tstrg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, \"lfs\", repoID))\n\tif stat, err := strg.Stat(path.Join(\"objects\", pointer.RelativePath())); err == nil {\n\t\t// Verify object is in the database.\n\t\tobj, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), pointer.Oid)\n\t\tif err != nil {\n\t\t\tif errors.Is(err, db.ErrRecordNotFound) {\n\t\t\t\tlogger.Error(\"object not found\", \"oid\", pointer.Oid)\n\t\t\t\trenderJSON(w, http.StatusNotFound, lfs.ErrorResponse{\n\t\t\t\t\tMessage: \"object not found\",\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\t\t\tlogger.Error(\"error getting object\", \"oid\", pointer.Oid, \"err\", err)\n\t\t\trenderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{\n\t\t\t\tMessage: \"internal server error\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tif obj.Size != pointer.Size {\n\t\t\trenderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{\n\t\t\t\tMessage: \"object size mismatch\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tif pointer.IsValid() && stat.Size() == pointer.Size {\n\t\t\trenderStatus(http.StatusOK)(w, nil)\n\t\t\treturn\n\t\t}\n\t} else if errors.Is(err, fs.ErrNotExist) {\n\t\tlogger.Error(\"file not found\", \"oid\", pointer.Oid)\n\t\trenderJSON(w, http.StatusNotFound, lfs.ErrorResponse{\n\t\t\tMessage: \"object not found\",\n\t\t})\n\t\treturn\n\t} else {\n\t\tlogger.Error(\"error getting object\", \"oid\", pointer.Oid, \"err\", err)\n\t\trenderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{\n\t\t\tMessage: \"internal server error\",\n\t\t})\n\t\treturn\n\t}\n}\n\nfunc serviceLfsLocks(w http.ResponseWriter, r *http.Request) {\n\tswitch r.Method {\n\tcase http.MethodGet:\n\t\tserviceLfsLocksGet(w, r)\n\tcase http.MethodPost:\n\t\tserviceLfsLocksCreate(w, r)\n\tdefault:\n\t\trenderMethodNotAllowed(w, r)\n\t}\n}\n\n// POST: /<repo>.git/info/lfs/objects/locks\nfunc serviceLfsLocksCreate(w http.ResponseWriter, r *http.Request) {\n\tif !isLfs(r) {\n\t\trenderNotAcceptable(w)\n\t\treturn\n\t}\n\n\tctx := r.Context()\n\tlogger := log.FromContext(ctx).WithPrefix(\"http.lfs-locks\")\n\n\tvar req lfs.LockCreateRequest\n\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\tlogger.Error(\"error decoding json\", \"err\", err)\n\t\trenderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{\n\t\t\tMessage: \"invalid request: \" + err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\trepo := proto.RepositoryFromContext(ctx)\n\tif repo == nil {\n\t\tlogger.Error(\"error getting repository from context\")\n\t\trenderJSON(w, http.StatusNotFound, lfs.ErrorResponse{\n\t\t\tMessage: \"repository not found\",\n\t\t})\n\t\treturn\n\t}\n\n\tuser := proto.UserFromContext(ctx)\n\tif user == nil {\n\t\tlogger.Error(\"error getting user from context\")\n\t\trenderJSON(w, http.StatusNotFound, lfs.ErrorResponse{\n\t\t\tMessage: \"user not found\",\n\t\t})\n\t\treturn\n\t}\n\n\tdbx := db.FromContext(ctx)\n\tdatastore := store.FromContext(ctx)\n\tif err := datastore.CreateLFSLockForUser(ctx, dbx, repo.ID(), user.ID(), req.Path, req.Ref.Name); err != nil {\n\t\terr = db.WrapError(err)\n\t\tif errors.Is(err, db.ErrDuplicateKey) {\n\t\t\terrResp := lfs.LockResponse{\n\t\t\t\tErrorResponse: lfs.ErrorResponse{\n\t\t\t\t\tMessage: \"lock already exists\",\n\t\t\t\t},\n\t\t\t}\n\t\t\tlock, err := datastore.GetLFSLockForUserPath(ctx, dbx, repo.ID(), user.ID(), req.Path)\n\t\t\tif err == nil {\n\t\t\t\terrResp.Lock = lfs.Lock{\n\t\t\t\t\tID:       strconv.FormatInt(lock.ID, 10),\n\t\t\t\t\tPath:     lock.Path,\n\t\t\t\t\tLockedAt: lock.CreatedAt,\n\t\t\t\t}\n\t\t\t\tlockOwner := lfs.Owner{\n\t\t\t\t\tName: user.Username(),\n\t\t\t\t}\n\t\t\t\tif lock.UserID != user.ID() {\n\t\t\t\t\towner, err := datastore.GetUserByID(ctx, dbx, lock.UserID)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tlogger.Error(\"error getting lock owner\", \"err\", err)\n\t\t\t\t\t\trenderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{\n\t\t\t\t\t\t\tMessage: \"internal server error\",\n\t\t\t\t\t\t})\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tlockOwner.Name = owner.Username\n\t\t\t\t}\n\t\t\t\terrResp.Lock.Owner = lockOwner\n\t\t\t}\n\t\t\trenderJSON(w, http.StatusConflict, errResp)\n\t\t\treturn\n\t\t}\n\t\tlogger.Error(\"error creating lock\", \"err\", err)\n\t\trenderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{\n\t\t\tMessage: \"internal server error\",\n\t\t})\n\t\treturn\n\t}\n\n\tlock, err := datastore.GetLFSLockForUserPath(ctx, dbx, repo.ID(), user.ID(), req.Path)\n\tif err != nil {\n\t\tlogger.Error(\"error getting lock\", \"err\", err)\n\t\trenderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{\n\t\t\tMessage: \"internal server error\",\n\t\t})\n\t\treturn\n\t}\n\n\trenderJSON(w, http.StatusCreated, lfs.LockResponse{\n\t\tLock: lfs.Lock{\n\t\t\tID:       strconv.FormatInt(lock.ID, 10),\n\t\t\tPath:     lock.Path,\n\t\t\tLockedAt: lock.CreatedAt,\n\t\t\tOwner: lfs.Owner{\n\t\t\t\tName: user.Username(),\n\t\t\t},\n\t\t},\n\t})\n}\n\n// GET: /<repo>.git/info/lfs/objects/locks\nfunc serviceLfsLocksGet(w http.ResponseWriter, r *http.Request) {\n\taccept := r.Header.Get(\"Accept\")\n\tif !strings.HasPrefix(accept, lfs.MediaType) {\n\t\trenderNotAcceptable(w)\n\t\treturn\n\t}\n\n\tparseLocksQuery := func(values url.Values) (path string, id int64, cursor int, limit int, refspec string) {\n\t\tpath = values.Get(\"path\")\n\t\tidStr := values.Get(\"id\")\n\t\tif idStr != \"\" {\n\t\t\tid, _ = strconv.ParseInt(idStr, 10, 64)\n\t\t}\n\t\tcursorStr := values.Get(\"cursor\")\n\t\tif cursorStr != \"\" {\n\t\t\tcursor, _ = strconv.Atoi(cursorStr)\n\t\t}\n\t\tlimitStr := values.Get(\"limit\")\n\t\tif limitStr != \"\" {\n\t\t\tlimit, _ = strconv.Atoi(limitStr)\n\t\t}\n\t\trefspec = values.Get(\"refspec\")\n\t\treturn\n\t}\n\n\tctx := r.Context()\n\t// TODO: respect refspec\n\tpath, id, cursor, limit, _ := parseLocksQuery(r.URL.Query())\n\tif limit > 100 {\n\t\tlimit = 100\n\t} else if limit <= 0 {\n\t\tlimit = lfs.DefaultLocksLimit\n\t}\n\n\t// cursor is the page number\n\tif cursor <= 0 {\n\t\tcursor = 1\n\t}\n\n\tlogger := log.FromContext(ctx).WithPrefix(\"http.lfs-locks\")\n\tdbx := db.FromContext(ctx)\n\tdatastore := store.FromContext(ctx)\n\trepo := proto.RepositoryFromContext(ctx)\n\tif repo == nil {\n\t\tlogger.Error(\"error getting repository from context\")\n\t\trenderJSON(w, http.StatusNotFound, lfs.ErrorResponse{\n\t\t\tMessage: \"repository not found\",\n\t\t})\n\t\treturn\n\t}\n\n\tif id > 0 {\n\t\tlock, err := datastore.GetLFSLockByID(ctx, dbx, id)\n\t\tif err != nil {\n\t\t\tif errors.Is(err, db.ErrRecordNotFound) {\n\t\t\t\trenderJSON(w, http.StatusNotFound, lfs.ErrorResponse{\n\t\t\t\t\tMessage: \"lock not found\",\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\t\t\tlogger.Error(\"error getting lock\", \"err\", err)\n\t\t\trenderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{\n\t\t\t\tMessage: \"internal server error\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\towner, err := datastore.GetUserByID(ctx, dbx, lock.UserID)\n\t\tif err != nil {\n\t\t\tlogger.Error(\"error getting lock owner\", \"err\", err)\n\t\t\trenderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{\n\t\t\t\tMessage: \"internal server error\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\trenderJSON(w, http.StatusOK, lfs.LockListResponse{\n\t\t\tLocks: []lfs.Lock{\n\t\t\t\t{\n\t\t\t\t\tID:       strconv.FormatInt(lock.ID, 10),\n\t\t\t\t\tPath:     lock.Path,\n\t\t\t\t\tLockedAt: lock.CreatedAt,\n\t\t\t\t\tOwner: lfs.Owner{\n\t\t\t\t\t\tName: owner.Username,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\treturn\n\t} else if path != \"\" {\n\t\tlock, err := datastore.GetLFSLockForPath(ctx, dbx, repo.ID(), path)\n\t\tif err != nil {\n\t\t\tif errors.Is(err, db.ErrRecordNotFound) {\n\t\t\t\trenderJSON(w, http.StatusNotFound, lfs.ErrorResponse{\n\t\t\t\t\tMessage: \"lock not found\",\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\t\t\tlogger.Error(\"error getting lock\", \"err\", err)\n\t\t\trenderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{\n\t\t\t\tMessage: \"internal server error\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\towner, err := datastore.GetUserByID(ctx, dbx, lock.UserID)\n\t\tif err != nil {\n\t\t\tlogger.Error(\"error getting lock owner\", \"err\", err)\n\t\t\trenderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{\n\t\t\t\tMessage: \"internal server error\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\trenderJSON(w, http.StatusOK, lfs.LockListResponse{\n\t\t\tLocks: []lfs.Lock{\n\t\t\t\t{\n\t\t\t\t\tID:       strconv.FormatInt(lock.ID, 10),\n\t\t\t\t\tPath:     lock.Path,\n\t\t\t\t\tLockedAt: lock.CreatedAt,\n\t\t\t\t\tOwner: lfs.Owner{\n\t\t\t\t\t\tName: owner.Username,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\treturn\n\t}\n\n\tlocks, err := datastore.GetLFSLocks(ctx, dbx, repo.ID(), cursor, limit)\n\tif err != nil {\n\t\tlogger.Error(\"error getting locks\", \"err\", err)\n\t\trenderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{\n\t\t\tMessage: \"internal server error\",\n\t\t})\n\t\treturn\n\t}\n\n\tlockList := make([]lfs.Lock, len(locks))\n\tusers := map[int64]models.User{}\n\tfor i, lock := range locks {\n\t\towner, ok := users[lock.UserID]\n\t\tif !ok {\n\t\t\towner, err = datastore.GetUserByID(ctx, dbx, lock.UserID)\n\t\t\tif err != nil {\n\t\t\t\tlogger.Error(\"error getting lock owner\", \"err\", err)\n\t\t\t\trenderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{\n\t\t\t\t\tMessage: \"internal server error\",\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\t\t\tusers[lock.UserID] = owner\n\t\t}\n\n\t\tlockList[i] = lfs.Lock{\n\t\t\tID:       strconv.FormatInt(lock.ID, 10),\n\t\t\tPath:     lock.Path,\n\t\t\tLockedAt: lock.CreatedAt,\n\t\t\tOwner: lfs.Owner{\n\t\t\t\tName: owner.Username,\n\t\t\t},\n\t\t}\n\t}\n\n\tresp := lfs.LockListResponse{\n\t\tLocks: lockList,\n\t}\n\tif len(locks) == limit {\n\t\tresp.NextCursor = strconv.Itoa(cursor + 1)\n\t}\n\n\trenderJSON(w, http.StatusOK, resp)\n}\n\n// POST: /<repo>.git/info/lfs/objects/locks/verify\nfunc serviceLfsLocksVerify(w http.ResponseWriter, r *http.Request) {\n\tif !isLfs(r) {\n\t\trenderNotAcceptable(w)\n\t\treturn\n\t}\n\n\tctx := r.Context()\n\tlogger := log.FromContext(ctx).WithPrefix(\"http.lfs-locks\")\n\trepo := proto.RepositoryFromContext(ctx)\n\tif repo == nil {\n\t\tlogger.Error(\"error getting repository from context\")\n\t\trenderJSON(w, http.StatusNotFound, lfs.ErrorResponse{\n\t\t\tMessage: \"repository not found\",\n\t\t})\n\t\treturn\n\t}\n\n\tvar req lfs.LockVerifyRequest\n\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\tlogger.Error(\"error decoding request\", \"err\", err)\n\t\trenderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{\n\t\t\tMessage: \"invalid request: \" + err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\t// TODO: refspec\n\tcursor, _ := strconv.Atoi(req.Cursor)\n\tif cursor <= 0 {\n\t\tcursor = 1\n\t}\n\n\tlimit := req.Limit\n\tif limit > 100 {\n\t\tlimit = 100\n\t} else if limit <= 0 {\n\t\tlimit = lfs.DefaultLocksLimit\n\t}\n\n\tdbx := db.FromContext(ctx)\n\tdatastore := store.FromContext(ctx)\n\tuser := proto.UserFromContext(ctx)\n\tours := make([]lfs.Lock, 0)\n\ttheirs := make([]lfs.Lock, 0)\n\n\tvar resp lfs.LockVerifyResponse\n\tlocks, err := datastore.GetLFSLocks(ctx, dbx, repo.ID(), cursor, limit)\n\tif err != nil {\n\t\tlogger.Error(\"error getting locks\", \"err\", err)\n\t\trenderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{\n\t\t\tMessage: \"internal server error\",\n\t\t})\n\t\treturn\n\t}\n\n\tusers := map[int64]models.User{}\n\tfor _, lock := range locks {\n\t\towner, ok := users[lock.UserID]\n\t\tif !ok {\n\t\t\towner, err = datastore.GetUserByID(ctx, dbx, lock.UserID)\n\t\t\tif err != nil {\n\t\t\t\tlogger.Error(\"error getting lock owner\", \"err\", err)\n\t\t\t\trenderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{\n\t\t\t\t\tMessage: \"internal server error\",\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\t\t\tusers[lock.UserID] = owner\n\t\t}\n\n\t\tl := lfs.Lock{\n\t\t\tID:       strconv.FormatInt(lock.ID, 10),\n\t\t\tPath:     lock.Path,\n\t\t\tLockedAt: lock.CreatedAt,\n\t\t\tOwner: lfs.Owner{\n\t\t\t\tName: owner.Username,\n\t\t\t},\n\t\t}\n\n\t\tif user != nil && user.ID() == lock.UserID {\n\t\t\tours = append(ours, l)\n\t\t} else {\n\t\t\ttheirs = append(theirs, l)\n\t\t}\n\t}\n\n\tresp.Ours = ours\n\tresp.Theirs = theirs\n\n\tif len(locks) == limit {\n\t\tresp.NextCursor = strconv.Itoa(cursor + 1)\n\t}\n\n\trenderJSON(w, http.StatusOK, resp)\n}\n\n// POST: /<repo>.git/info/lfs/objects/locks/:lockID/unlock\nfunc serviceLfsLocksDelete(w http.ResponseWriter, r *http.Request) {\n\tif !isLfs(r) {\n\t\trenderNotAcceptable(w)\n\t\treturn\n\t}\n\n\tctx := r.Context()\n\tlogger := log.FromContext(ctx).WithPrefix(\"http.lfs-locks\")\n\tlockIDStr := mux.Vars(r)[\"lock_id\"]\n\tif lockIDStr == \"\" {\n\t\tlogger.Error(\"error getting lock id\")\n\t\trenderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{\n\t\t\tMessage: \"invalid request\",\n\t\t})\n\t\treturn\n\t}\n\n\tlockID, err := strconv.ParseInt(lockIDStr, 10, 64)\n\tif err != nil {\n\t\tlogger.Error(\"error parsing lock id\", \"err\", err)\n\t\trenderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{\n\t\t\tMessage: \"invalid request\",\n\t\t})\n\t\treturn\n\t}\n\n\tvar req lfs.LockDeleteRequest\n\tif err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n\t\tlogger.Error(\"error decoding request\", \"err\", err)\n\t\trenderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{\n\t\t\tMessage: \"invalid request: \" + err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\tdbx := db.FromContext(ctx)\n\tdatastore := store.FromContext(ctx)\n\trepo := proto.RepositoryFromContext(ctx)\n\tif repo == nil {\n\t\tlogger.Error(\"error getting repository from context\")\n\t\trenderJSON(w, http.StatusNotFound, lfs.ErrorResponse{\n\t\t\tMessage: \"repository not found\",\n\t\t})\n\t\treturn\n\t}\n\n\t// The lock being deleted\n\tlock, err := datastore.GetLFSLockByID(ctx, dbx, lockID)\n\tif err != nil {\n\t\tlogger.Error(\"error getting lock\", \"err\", err)\n\t\trenderJSON(w, http.StatusNotFound, lfs.ErrorResponse{\n\t\t\tMessage: \"lock not found\",\n\t\t})\n\t\treturn\n\t}\n\n\towner, err := datastore.GetUserByID(ctx, dbx, lock.UserID)\n\tif err != nil {\n\t\tlogger.Error(\"error getting lock owner\", \"err\", err)\n\t\trenderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{\n\t\t\tMessage: \"internal server error\",\n\t\t})\n\t\treturn\n\t}\n\n\tl := lfs.Lock{\n\t\tID:       strconv.FormatInt(lock.ID, 10),\n\t\tPath:     lock.Path,\n\t\tLockedAt: lock.CreatedAt,\n\t\tOwner: lfs.Owner{\n\t\t\tName: owner.Username,\n\t\t},\n\t}\n\n\t// Retrieve user context first for authorization checks\n\tuser := proto.UserFromContext(ctx)\n\tif user == nil {\n\t\tlogger.Error(\"error getting user from context\")\n\t\trenderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{\n\t\t\tMessage: \"unauthorized\",\n\t\t})\n\t\treturn\n\t}\n\n\t// Force delete another user's lock (requires admin privileges)\n\tif req.Force {\n\t\tif !user.IsAdmin() {\n\t\t\tlogger.Error(\"non-admin user attempted force delete\", \"user\", user.Username())\n\t\t\trenderJSON(w, http.StatusForbidden, lfs.ErrorResponse{\n\t\t\t\tMessage: \"admin access required for force delete\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tif err := datastore.DeleteLFSLock(ctx, dbx, repo.ID(), lockID); err != nil {\n\t\t\tlogger.Error(\"error deleting lock\", \"err\", err)\n\t\t\trenderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{\n\t\t\t\tMessage: \"internal server error\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\trenderJSON(w, http.StatusOK, l)\n\t\treturn\n\t}\n\n\t// Delete our own lock - verify ownership\n\tif owner.ID != user.ID() {\n\t\tlogger.Error(\"error deleting another user's lock\")\n\t\trenderJSON(w, http.StatusForbidden, lfs.ErrorResponse{\n\t\t\tMessage: \"lock belongs to another user\",\n\t\t})\n\t\treturn\n\t}\n\n\tif err := datastore.DeleteLFSLock(ctx, dbx, repo.ID(), lockID); err != nil {\n\t\tlogger.Error(\"error deleting lock\", \"err\", err)\n\t\trenderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{\n\t\t\tMessage: \"internal server error\",\n\t\t})\n\t\treturn\n\t}\n\n\trenderJSON(w, http.StatusOK, lfs.LockResponse{Lock: l})\n}\n\n// renderJSON renders a JSON response with the given status code and value. It\n// also sets the Content-Type header to the JSON LFS media type (application/vnd.git-lfs+json).\nfunc renderJSON(w http.ResponseWriter, statusCode int, v interface{}) {\n\thdrLfs(w)\n\tw.WriteHeader(statusCode)\n\tif err := json.NewEncoder(w).Encode(v); err != nil {\n\t\tlog.Error(\"error encoding json\", \"err\", err)\n\t}\n}\n\nfunc renderNotAcceptable(w http.ResponseWriter) {\n\trenderStatus(http.StatusNotAcceptable)(w, nil)\n}\n\nfunc isLfs(r *http.Request) bool {\n\tcontentType := r.Header.Get(\"Content-Type\")\n\taccept := r.Header.Get(\"Accept\")\n\treturn strings.HasPrefix(contentType, lfs.MediaType) && strings.HasPrefix(accept, lfs.MediaType)\n}\n\nfunc isBinary(r *http.Request) bool {\n\tcontentType := r.Header.Get(\"Content-Type\")\n\treturn strings.HasPrefix(contentType, \"application/octet-stream\")\n}\n\nfunc hdrLfs(w http.ResponseWriter) {\n\tw.Header().Set(\"Content-Type\", lfs.MediaType)\n\tw.Header().Set(\"Accept\", lfs.MediaType)\n}\n"
  },
  {
    "path": "pkg/web/goget.go",
    "content": "package web\n\nimport (\n\t\"net/http\"\n\t\"net/url\"\n\t\"path\"\n\t\"text/template\"\n\n\t\"charm.land/log/v2\"\n\t\"github.com/charmbracelet/soft-serve/pkg/backend\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/utils\"\n\t\"github.com/gorilla/mux\"\n\t\"github.com/prometheus/client_golang/prometheus\"\n\t\"github.com/prometheus/client_golang/prometheus/promauto\"\n)\n\nvar goGetCounter = promauto.NewCounterVec(prometheus.CounterOpts{\n\tNamespace: \"soft_serve\",\n\tSubsystem: \"http\",\n\tName:      \"go_get_total\",\n\tHelp:      \"The total number of go get requests\",\n}, []string{\"repo\"})\n\nvar repoIndexHTMLTpl = template.Must(template.New(\"index\").Parse(`<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\"/>\n    <meta http-equiv=\"refresh\" content=\"0; url=https://godoc.org/{{ .ImportRoot }}/{{.Repo}}\">\n    <meta name=\"go-import\" content=\"{{ .ImportRoot }}/{{ .Repo }} git {{ .Config.HTTP.PublicURL }}/{{ .Repo }}.git\">\n</head>\n<body>\nRedirecting to docs at <a href=\"https://godoc.org/{{ .ImportRoot }}/{{ .Repo }}\">godoc.org/{{ .ImportRoot }}/{{ .Repo }}</a>...\n</body>\n</html>\n`))\n\n// GoGetHandler handles go get requests.\nfunc GoGetHandler(w http.ResponseWriter, r *http.Request) {\n\tctx := r.Context()\n\tcfg := config.FromContext(ctx)\n\tbe := backend.FromContext(ctx)\n\tlogger := log.FromContext(ctx)\n\trepo := mux.Vars(r)[\"repo\"]\n\n\t// Handle go get requests.\n\t//\n\t// Always return a 200 status code, even if the repo path doesn't exist.\n\t// It will try to find the repo by walking up the path until it finds one.\n\t// If it can't find one, it will return a 404.\n\t//\n\t// https://golang.org/cmd/go/#hdr-Remote_import_paths\n\t// https://go.dev/ref/mod#vcs-branch\n\tif r.URL.Query().Get(\"go-get\") == \"1\" {\n\t\trepo := repo\n\t\timportRoot, err := url.Parse(cfg.HTTP.PublicURL)\n\t\tif err != nil {\n\t\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\n\t\t// find the repo\n\t\tfor {\n\t\t\tif _, err := be.Repository(ctx, repo); err == nil {\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tif repo == \"\" || repo == \".\" || repo == \"/\" {\n\t\t\t\trenderNotFound(w, r)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trepo = path.Dir(repo)\n\t\t}\n\n\t\tif err := repoIndexHTMLTpl.Execute(w, struct {\n\t\t\tRepo       string\n\t\t\tConfig     *config.Config\n\t\t\tImportRoot string\n\t\t}{\n\t\t\tRepo:       utils.SanitizeRepo(repo),\n\t\t\tConfig:     cfg,\n\t\t\tImportRoot: importRoot.Host,\n\t\t}); err != nil {\n\t\t\tlogger.Error(\"failed to render go get template\", \"err\", err)\n\t\t\trenderInternalServerError(w, r)\n\t\t\treturn\n\t\t}\n\n\t\tgoGetCounter.WithLabelValues(repo).Inc()\n\t\treturn\n\t}\n\n\trenderNotFound(w, r)\n}\n"
  },
  {
    "path": "pkg/web/health.go",
    "content": "package web\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\n\t\"charm.land/log/v2\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/gorilla/mux\"\n)\n\n// HealthController registers the health check routes for the web server.\nfunc HealthController(_ context.Context, r *mux.Router) {\n\tr.HandleFunc(\"/livez\", getLiveness)\n\tr.HandleFunc(\"/readyz\", getReadiness)\n}\n\nfunc getLiveness(w http.ResponseWriter, _ *http.Request) {\n\trenderStatus(http.StatusOK)(w, nil)\n}\n\nfunc getReadiness(w http.ResponseWriter, r *http.Request) {\n\tctx := r.Context()\n\tlogger := log.FromContext(ctx)\n\tdb := db.FromContext(ctx)\n\n\tif err := db.PingContext(ctx); err != nil {\n\t\tlogger.Error(\"error getting db readiness\", \"err\", err)\n\t\trenderStatus(http.StatusServiceUnavailable)(w, nil)\n\t\treturn\n\t}\n\n\trenderStatus(http.StatusOK)(w, nil)\n}\n"
  },
  {
    "path": "pkg/web/http.go",
    "content": "package web\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"charm.land/log/v2\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n)\n\n// HTTPServer is an http server.\ntype HTTPServer struct {\n\tctx context.Context\n\tcfg *config.Config\n\n\tServer *http.Server\n}\n\n// NewHTTPServer creates a new HTTP server.\nfunc NewHTTPServer(ctx context.Context) (*HTTPServer, error) {\n\tcfg := config.FromContext(ctx)\n\tlogger := log.FromContext(ctx)\n\ts := &HTTPServer{\n\t\tctx: ctx,\n\t\tcfg: cfg,\n\t\tServer: &http.Server{\n\t\t\tAddr:              cfg.HTTP.ListenAddr,\n\t\t\tHandler:           NewRouter(ctx),\n\t\t\tReadHeaderTimeout: time.Second * 10,\n\t\t\tIdleTimeout:       time.Second * 10,\n\t\t\tMaxHeaderBytes:    http.DefaultMaxHeaderBytes,\n\t\t\tErrorLog:          logger.StandardLog(log.StandardLogOptions{ForceLevel: log.ErrorLevel}),\n\t\t},\n\t}\n\n\treturn s, nil\n}\n\n// SetTLSConfig sets the TLS configuration for the HTTP server.\nfunc (s *HTTPServer) SetTLSConfig(tlsConfig *tls.Config) {\n\ts.Server.TLSConfig = tlsConfig\n}\n\n// Close closes the HTTP server.\nfunc (s *HTTPServer) Close() error {\n\treturn s.Server.Close()\n}\n\n// ListenAndServe starts the HTTP server.\nfunc (s *HTTPServer) ListenAndServe() error {\n\tif s.Server.TLSConfig != nil {\n\t\treturn s.Server.ListenAndServeTLS(\"\", \"\")\n\t}\n\treturn s.Server.ListenAndServe()\n}\n\n// Shutdown gracefully shuts down the HTTP server.\nfunc (s *HTTPServer) Shutdown(ctx context.Context) error {\n\treturn s.Server.Shutdown(ctx)\n}\n"
  },
  {
    "path": "pkg/web/logging.go",
    "content": "package web\n\nimport (\n\t\"bufio\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"charm.land/log/v2\"\n\t\"github.com/dustin/go-humanize\"\n)\n\n// logWriter is a wrapper around http.ResponseWriter that allows us to capture\n// the HTTP status code and bytes written to the response.\ntype logWriter struct {\n\thttp.ResponseWriter\n\tcode, bytes int\n}\n\nvar (\n\t_ http.ResponseWriter = (*logWriter)(nil)\n\t_ http.Flusher        = (*logWriter)(nil)\n\t_ http.Hijacker       = (*logWriter)(nil)\n\t_ http.CloseNotifier  = (*logWriter)(nil)\n)\n\n// Write implements http.ResponseWriter.\nfunc (r *logWriter) Write(p []byte) (int, error) {\n\twritten, err := r.ResponseWriter.Write(p)\n\tr.bytes += written\n\treturn written, err\n}\n\n// Note this is generally only called when sending an HTTP error, so it's\n// important to set the `code` value to 200 as a default.\nfunc (r *logWriter) WriteHeader(code int) {\n\tr.code = code\n\tr.ResponseWriter.WriteHeader(code)\n}\n\n// Unwrap returns the underlying http.ResponseWriter.\nfunc (r *logWriter) Unwrap() http.ResponseWriter {\n\treturn r.ResponseWriter\n}\n\n// Flush implements http.Flusher.\nfunc (r *logWriter) Flush() {\n\tif f, ok := r.ResponseWriter.(http.Flusher); ok {\n\t\tf.Flush()\n\t}\n}\n\n// CloseNotify implements http.CloseNotifier.\nfunc (r *logWriter) CloseNotify() <-chan bool {\n\tif cn, ok := r.ResponseWriter.(http.CloseNotifier); ok {\n\t\treturn cn.CloseNotify()\n\t}\n\treturn nil\n}\n\n// Hijack implements http.Hijacker.\nfunc (r *logWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {\n\tif h, ok := r.ResponseWriter.(http.Hijacker); ok {\n\t\treturn h.Hijack()\n\t}\n\treturn nil, nil, fmt.Errorf(\"http.Hijacker not implemented\")\n}\n\n// NewLoggingMiddleware returns a new logging middleware.\nfunc NewLoggingMiddleware(next http.Handler, logger *log.Logger) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tstart := time.Now()\n\t\twriter := &logWriter{code: http.StatusOK, ResponseWriter: w}\n\t\tlogger.Debug(\"request\",\n\t\t\t\"method\", r.Method,\n\t\t\t\"path\", r.URL,\n\t\t\t\"addr\", r.RemoteAddr)\n\t\tnext.ServeHTTP(writer, r)\n\t\telapsed := time.Since(start)\n\t\tlogger.Debug(\"response\",\n\t\t\t\"status\", fmt.Sprintf(\"%d %s\", writer.code, http.StatusText(writer.code)),\n\t\t\t\"bytes\", humanize.Bytes(uint64(writer.bytes)), //nolint:gosec\n\t\t\t\"time\", elapsed)\n\t})\n}\n"
  },
  {
    "path": "pkg/web/server.go",
    "content": "package web\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\n\t\"charm.land/log/v2\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/gorilla/handlers\"\n\t\"github.com/gorilla/mux\"\n)\n\n// NewRouter returns a new HTTP router.\nfunc NewRouter(ctx context.Context) http.Handler {\n\tlogger := log.FromContext(ctx).WithPrefix(\"http\")\n\trouter := mux.NewRouter()\n\n\t// Health routes\n\tHealthController(ctx, router)\n\n\t// Git routes\n\tGitController(ctx, router)\n\n\trouter.PathPrefix(\"/\").HandlerFunc(renderNotFound)\n\n\t// Context handler\n\t// Adds context to the request\n\th := NewLoggingMiddleware(router, logger)\n\th = NewContextHandler(ctx)(h)\n\th = handlers.CompressHandler(h)\n\th = handlers.RecoveryHandler()(h)\n\n\tcfg := config.FromContext(ctx)\n\n\th = handlers.CORS(handlers.AllowedHeaders(cfg.HTTP.CORS.AllowedHeaders),\n\t\thandlers.AllowedOrigins(cfg.HTTP.CORS.AllowedOrigins),\n\t\thandlers.AllowedMethods(cfg.HTTP.CORS.AllowedMethods),\n\t)(h)\n\n\treturn h\n}\n"
  },
  {
    "path": "pkg/web/util.go",
    "content": "package web\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n)\n\nfunc renderStatus(code int) http.HandlerFunc {\n\treturn func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(code)\n\t\tio.WriteString(w, fmt.Sprintf(\"%d %s\", code, http.StatusText(code))) //nolint: errcheck\n\t}\n}\n"
  },
  {
    "path": "pkg/webhook/branch_tag.go",
    "content": "package webhook\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/charmbracelet/soft-serve/git\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/store\"\n)\n\n// BranchTagEvent is a branch or tag event.\ntype BranchTagEvent struct {\n\tCommon\n\n\t// Ref is the branch or tag name.\n\tRef string `json:\"ref\" url:\"ref\"`\n\t// Before is the previous commit SHA.\n\tBefore string `json:\"before\" url:\"before\"`\n\t// After is the current commit SHA.\n\tAfter string `json:\"after\" url:\"after\"`\n\t// Created is whether the branch or tag was created.\n\tCreated bool `json:\"created\" url:\"created\"`\n\t// Deleted is whether the branch or tag was deleted.\n\tDeleted bool `json:\"deleted\" url:\"deleted\"`\n}\n\n// NewBranchTagEvent sends a branch or tag event.\nfunc NewBranchTagEvent(ctx context.Context, user proto.User, repo proto.Repository, ref, before, after string) (BranchTagEvent, error) {\n\tvar event Event\n\tif git.IsZeroHash(before) {\n\t\tevent = EventBranchTagCreate\n\t} else if git.IsZeroHash(after) {\n\t\tevent = EventBranchTagDelete\n\t} else {\n\t\treturn BranchTagEvent{}, fmt.Errorf(\"invalid branch or tag event: before=%q after=%q\", before, after)\n\t}\n\n\tpayload := BranchTagEvent{\n\t\tRef:     ref,\n\t\tBefore:  before,\n\t\tAfter:   after,\n\t\tCreated: git.IsZeroHash(before),\n\t\tDeleted: git.IsZeroHash(after),\n\t\tCommon: Common{\n\t\t\tEventType: event,\n\t\t\tRepository: Repository{\n\t\t\t\tID:          repo.ID(),\n\t\t\t\tName:        repo.Name(),\n\t\t\t\tDescription: repo.Description(),\n\t\t\t\tProjectName: repo.ProjectName(),\n\t\t\t\tPrivate:     repo.IsPrivate(),\n\t\t\t\tCreatedAt:   repo.CreatedAt(),\n\t\t\t\tUpdatedAt:   repo.UpdatedAt(),\n\t\t\t},\n\t\t\tSender: User{\n\t\t\t\tID:       user.ID(),\n\t\t\t\tUsername: user.Username(),\n\t\t\t},\n\t\t},\n\t}\n\n\tcfg := config.FromContext(ctx)\n\tpayload.Repository.HTTPURL = repoURL(cfg.HTTP.PublicURL, repo.Name())\n\tpayload.Repository.SSHURL = repoURL(cfg.SSH.PublicURL, repo.Name())\n\tpayload.Repository.GitURL = repoURL(cfg.Git.PublicURL, repo.Name())\n\n\t// Find repo owner.\n\tdbx := db.FromContext(ctx)\n\tdatastore := store.FromContext(ctx)\n\towner, err := datastore.GetUserByID(ctx, dbx, repo.UserID())\n\tif err != nil {\n\t\treturn BranchTagEvent{}, db.WrapError(err)\n\t}\n\n\tpayload.Repository.Owner.ID = owner.ID\n\tpayload.Repository.Owner.Username = owner.Username\n\tpayload.Repository.DefaultBranch, _ = getDefaultBranch(repo)\n\n\treturn payload, nil\n}\n"
  },
  {
    "path": "pkg/webhook/collaborator.go",
    "content": "package webhook\n\nimport (\n\t\"context\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/access\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/store\"\n)\n\n// CollaboratorEvent is a collaborator event.\ntype CollaboratorEvent struct {\n\tCommon\n\n\t// Action is the collaborator event action.\n\tAction CollaboratorEventAction `json:\"action\" url:\"action\"`\n\t// AccessLevel is the collaborator access level.\n\tAccessLevel access.AccessLevel `json:\"access_level\" url:\"access_level\"`\n\t// Collaborator is the collaborator.\n\tCollaborator User `json:\"collaborator\" url:\"collaborator\"`\n}\n\n// CollaboratorEventAction is a collaborator event action.\ntype CollaboratorEventAction string\n\nconst (\n\t// CollaboratorEventAdded is a collaborator added event.\n\tCollaboratorEventAdded CollaboratorEventAction = \"added\"\n\t// CollaboratorEventRemoved is a collaborator removed event.\n\tCollaboratorEventRemoved CollaboratorEventAction = \"removed\"\n)\n\n// NewCollaboratorEvent sends a collaborator event.\nfunc NewCollaboratorEvent(ctx context.Context, user proto.User, repo proto.Repository, collabUsername string, action CollaboratorEventAction) (CollaboratorEvent, error) {\n\tevent := EventCollaborator\n\n\tpayload := CollaboratorEvent{\n\t\tAction: action,\n\t\tCommon: Common{\n\t\t\tEventType: event,\n\t\t\tRepository: Repository{\n\t\t\t\tID:          repo.ID(),\n\t\t\t\tName:        repo.Name(),\n\t\t\t\tDescription: repo.Description(),\n\t\t\t\tProjectName: repo.ProjectName(),\n\t\t\t\tPrivate:     repo.IsPrivate(),\n\t\t\t\tCreatedAt:   repo.CreatedAt(),\n\t\t\t\tUpdatedAt:   repo.UpdatedAt(),\n\t\t\t},\n\t\t\tSender: User{\n\t\t\t\tID:       user.ID(),\n\t\t\t\tUsername: user.Username(),\n\t\t\t},\n\t\t},\n\t}\n\n\t// Find repo owner.\n\tdbx := db.FromContext(ctx)\n\tdatastore := store.FromContext(ctx)\n\towner, err := datastore.GetUserByID(ctx, dbx, repo.UserID())\n\tif err != nil {\n\t\treturn CollaboratorEvent{}, db.WrapError(err)\n\t}\n\n\tpayload.Repository.Owner.ID = owner.ID\n\tpayload.Repository.Owner.Username = owner.Username\n\tpayload.Repository.DefaultBranch, _ = getDefaultBranch(repo)\n\n\tcollab, err := datastore.GetCollabByUsernameAndRepo(ctx, dbx, collabUsername, repo.Name())\n\tif err != nil {\n\t\treturn CollaboratorEvent{}, err\n\t}\n\n\tpayload.AccessLevel = collab.AccessLevel\n\tpayload.Collaborator.ID = collab.UserID\n\tpayload.Collaborator.Username = collabUsername\n\n\treturn payload, nil\n}\n"
  },
  {
    "path": "pkg/webhook/common.go",
    "content": "package webhook\n\nimport \"time\"\n\n// EventPayload is a webhook event payload.\ntype EventPayload interface {\n\t// Event returns the event type.\n\tEvent() Event\n\t// RepositoryID returns the repository ID.\n\tRepositoryID() int64\n}\n\n// Common is a common payload.\ntype Common struct {\n\t// EventType is the event type.\n\tEventType Event `json:\"event\" url:\"event\"`\n\t// Repository is the repository payload.\n\tRepository Repository `json:\"repository\" url:\"repository\"`\n\t// Sender is the sender payload.\n\tSender User `json:\"sender\" url:\"sender\"`\n}\n\n// Event returns the event type.\n// Implements EventPayload.\nfunc (c Common) Event() Event {\n\treturn c.EventType\n}\n\n// RepositoryID returns the repository ID.\n// Implements EventPayload.\nfunc (c Common) RepositoryID() int64 {\n\treturn c.Repository.ID\n}\n\n// User represents a user in an event.\ntype User struct {\n\t// ID is the owner ID.\n\tID int64 `json:\"id\" url:\"id\"`\n\t// Username is the owner username.\n\tUsername string `json:\"username\" url:\"username\"`\n}\n\n// Repository represents an event repository.\ntype Repository struct {\n\t// ID is the repository ID.\n\tID int64 `json:\"id\" url:\"id\"`\n\t// Name is the repository name.\n\tName string `json:\"name\" url:\"name\"`\n\t// ProjectName is the repository project name.\n\tProjectName string `json:\"project_name\" url:\"project_name\"`\n\t// Description is the repository description.\n\tDescription string `json:\"description\" url:\"description\"`\n\t// DefaultBranch is the repository default branch.\n\tDefaultBranch string `json:\"default_branch\" url:\"default_branch\"`\n\t// Private is whether the repository is private.\n\tPrivate bool `json:\"private\" url:\"private\"`\n\t// Owner is the repository owner.\n\tOwner User `json:\"owner\" url:\"owner\"`\n\t// HTTPURL is the repository HTTP URL.\n\tHTTPURL string `json:\"http_url\" url:\"http_url\"`\n\t// SSHURL is the repository SSH URL.\n\tSSHURL string `json:\"ssh_url\" url:\"ssh_url\"`\n\t// GitURL is the repository Git URL.\n\tGitURL string `json:\"git_url\" url:\"git_url\"`\n\t// CreatedAt is the repository creation time.\n\tCreatedAt time.Time `json:\"created_at\" url:\"created_at\"`\n\t// UpdatedAt is the repository last update time.\n\tUpdatedAt time.Time `json:\"updated_at\" url:\"updated_at\"`\n}\n\n// Author is a commit author.\ntype Author struct {\n\t// Name is the author name.\n\tName string `json:\"name\" url:\"name\"`\n\t// Email is the author email.\n\tEmail string `json:\"email\" url:\"email\"`\n\t// Date is the author date.\n\tDate time.Time `json:\"date\" url:\"date\"`\n}\n\n// Commit represents a Git commit.\ntype Commit struct {\n\t// ID is the commit ID.\n\tID string `json:\"id\" url:\"id\"`\n\t// Message is the commit message.\n\tMessage string `json:\"message\" url:\"message\"`\n\t// Title is the commit title.\n\tTitle string `json:\"title\" url:\"title\"`\n\t// Author is the commit author.\n\tAuthor Author `json:\"author\" url:\"author\"`\n\t// Committer is the commit committer.\n\tCommitter Author `json:\"committer\" url:\"committer\"`\n\t// Timestamp is the commit timestamp.\n\tTimestamp time.Time `json:\"timestamp\" url:\"timestamp\"`\n}\n"
  },
  {
    "path": "pkg/webhook/content_type.go",
    "content": "package webhook\n\nimport (\n\t\"encoding\"\n\t\"errors\"\n\t\"strings\"\n)\n\n// ContentType is the type of content that will be sent in a webhook request.\ntype ContentType int8\n\nconst (\n\t// ContentTypeJSON is the JSON content type.\n\tContentTypeJSON ContentType = iota\n\t// ContentTypeForm is the form content type.\n\tContentTypeForm\n)\n\nvar contentTypeStrings = map[ContentType]string{\n\tContentTypeJSON: \"application/json\",\n\tContentTypeForm: \"application/x-www-form-urlencoded\",\n}\n\n// String returns the string representation of the content type.\nfunc (c ContentType) String() string {\n\treturn contentTypeStrings[c]\n}\n\nvar stringContentType = map[string]ContentType{\n\t\"application/json\":                  ContentTypeJSON,\n\t\"application/x-www-form-urlencoded\": ContentTypeForm,\n}\n\n// ErrInvalidContentType is returned when the content type is invalid.\nvar ErrInvalidContentType = errors.New(\"invalid content type\")\n\n// ParseContentType parses a content type string and returns the content type.\nfunc ParseContentType(s string) (ContentType, error) {\n\tfor k, v := range stringContentType {\n\t\tif strings.HasPrefix(s, k) {\n\t\t\treturn v, nil\n\t\t}\n\t}\n\n\treturn -1, ErrInvalidContentType\n}\n\nvar (\n\t_ encoding.TextMarshaler   = ContentType(0)\n\t_ encoding.TextUnmarshaler = (*ContentType)(nil)\n)\n\n// UnmarshalText implements encoding.TextUnmarshaler.\nfunc (c *ContentType) UnmarshalText(text []byte) error {\n\tct, err := ParseContentType(string(text))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t*c = ct\n\treturn nil\n}\n\n// MarshalText implements encoding.TextMarshaler.\nfunc (c ContentType) MarshalText() (text []byte, err error) {\n\tct := c.String()\n\tif ct == \"\" {\n\t\treturn nil, ErrInvalidContentType\n\t}\n\n\treturn []byte(ct), nil\n}\n"
  },
  {
    "path": "pkg/webhook/content_type_test.go",
    "content": "package webhook\n\nimport \"testing\"\n\nfunc TestParseContentType(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\ts    string\n\t\twant ContentType\n\t\terr  error\n\t}{\n\t\t{\n\t\t\tname: \"JSON\",\n\t\t\ts:    \"application/json\",\n\t\t\twant: ContentTypeJSON,\n\t\t},\n\t\t{\n\t\t\tname: \"Form\",\n\t\t\ts:    \"application/x-www-form-urlencoded\",\n\t\t\twant: ContentTypeForm,\n\t\t},\n\t\t{\n\t\t\tname: \"Invalid\",\n\t\t\ts:    \"application/invalid\",\n\t\t\terr:  ErrInvalidContentType,\n\t\t\twant: -1,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, err := ParseContentType(tt.s)\n\t\t\tif err != tt.err {\n\t\t\t\tt.Errorf(\"ParseContentType() error = %v, wantErr %v\", err, tt.err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"ParseContentType() got = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestUnmarshalText(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\ttext    []byte\n\t\twant    ContentType\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"JSON\",\n\t\t\ttext: []byte(\"application/json\"),\n\t\t\twant: ContentTypeJSON,\n\t\t},\n\t\t{\n\t\t\tname: \"Form\",\n\t\t\ttext: []byte(\"application/x-www-form-urlencoded\"),\n\t\t\twant: ContentTypeForm,\n\t\t},\n\t\t{\n\t\t\tname:    \"Invalid\",\n\t\t\ttext:    []byte(\"application/invalid\"),\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tc := new(ContentType)\n\t\t\tif err := c.UnmarshalText(tt.text); (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"ContentType.UnmarshalText() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t}\n\t\t\tif *c != tt.want {\n\t\t\t\tt.Errorf(\"ContentType.UnmarshalText() got = %v, want %v\", *c, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestMarshalText(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tc       ContentType\n\t\twant    []byte\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"JSON\",\n\t\t\tc:    ContentTypeJSON,\n\t\t\twant: []byte(\"application/json\"),\n\t\t},\n\t\t{\n\t\t\tname: \"Form\",\n\t\t\tc:    ContentTypeForm,\n\t\t\twant: []byte(\"application/x-www-form-urlencoded\"),\n\t\t},\n\t\t{\n\t\t\tname:    \"Invalid\",\n\t\t\tc:       ContentType(-1),\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tb, err := tt.c.MarshalText()\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"ContentType.MarshalText() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif string(b) != string(tt.want) {\n\t\t\t\tt.Errorf(\"ContentType.MarshalText() got = %v, want %v\", string(b), string(tt.want))\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/webhook/event.go",
    "content": "package webhook\n\nimport (\n\t\"encoding\"\n\t\"errors\"\n)\n\n// Event is a webhook event.\ntype Event int\n\nconst (\n\t// EventBranchTagCreate is a branch or tag create event.\n\tEventBranchTagCreate Event = 1\n\n\t// EventBranchTagDelete is a branch or tag delete event.\n\tEventBranchTagDelete Event = 2\n\n\t// EventCollaborator is a collaborator change event.\n\tEventCollaborator Event = 3\n\n\t// EventPush is a push event.\n\tEventPush Event = 4\n\n\t// EventRepository is a repository create, delete, rename event.\n\tEventRepository Event = 5\n\n\t// EventRepositoryVisibilityChange is a repository visibility change event.\n\tEventRepositoryVisibilityChange Event = 6\n)\n\n// Events return all events.\nfunc Events() []Event {\n\treturn []Event{\n\t\tEventBranchTagCreate,\n\t\tEventBranchTagDelete,\n\t\tEventCollaborator,\n\t\tEventPush,\n\t\tEventRepository,\n\t\tEventRepositoryVisibilityChange,\n\t}\n}\n\nvar eventStrings = map[Event]string{\n\tEventBranchTagCreate:            \"branch_tag_create\",\n\tEventBranchTagDelete:            \"branch_tag_delete\",\n\tEventCollaborator:               \"collaborator\",\n\tEventPush:                       \"push\",\n\tEventRepository:                 \"repository\",\n\tEventRepositoryVisibilityChange: \"repository_visibility_change\",\n}\n\n// String returns the string representation of the event.\nfunc (e Event) String() string {\n\treturn eventStrings[e]\n}\n\nvar stringEvent = map[string]Event{\n\t\"branch_tag_create\":            EventBranchTagCreate,\n\t\"branch_tag_delete\":            EventBranchTagDelete,\n\t\"collaborator\":                 EventCollaborator,\n\t\"push\":                         EventPush,\n\t\"repository\":                   EventRepository,\n\t\"repository_visibility_change\": EventRepositoryVisibilityChange,\n}\n\n// ErrInvalidEvent is returned when the event is invalid.\nvar ErrInvalidEvent = errors.New(\"invalid event\")\n\n// ParseEvent parses an event string and returns the event.\nfunc ParseEvent(s string) (Event, error) {\n\te, ok := stringEvent[s]\n\tif !ok {\n\t\treturn -1, ErrInvalidEvent\n\t}\n\n\treturn e, nil\n}\n\nvar (\n\t_ encoding.TextMarshaler   = Event(0)\n\t_ encoding.TextUnmarshaler = (*Event)(nil)\n)\n\n// UnmarshalText implements encoding.TextUnmarshaler.\nfunc (e *Event) UnmarshalText(text []byte) error {\n\tev, err := ParseEvent(string(text))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t*e = ev\n\treturn nil\n}\n\n// MarshalText implements encoding.TextMarshaler.\nfunc (e Event) MarshalText() (text []byte, err error) {\n\tev := e.String()\n\tif ev == \"\" {\n\t\treturn nil, ErrInvalidEvent\n\t}\n\n\treturn []byte(ev), nil\n}\n"
  },
  {
    "path": "pkg/webhook/push.go",
    "content": "package webhook\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\tgitm \"github.com/aymanbagabas/git-module\"\n\t\"github.com/charmbracelet/soft-serve/git\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/store\"\n)\n\n// PushEvent is a push event.\ntype PushEvent struct {\n\tCommon\n\n\t// Ref is the branch or tag name.\n\tRef string `json:\"ref\" url:\"ref\"`\n\t// Before is the previous commit SHA.\n\tBefore string `json:\"before\" url:\"before\"`\n\t// After is the current commit SHA.\n\tAfter string `json:\"after\" url:\"after\"`\n\t// Commits is the list of commits.\n\tCommits []Commit `json:\"commits\" url:\"commits\"`\n}\n\n// NewPushEvent sends a push event.\nfunc NewPushEvent(ctx context.Context, user proto.User, repo proto.Repository, ref, before, after string) (PushEvent, error) {\n\tevent := EventPush\n\n\tpayload := PushEvent{\n\t\tRef:    ref,\n\t\tBefore: before,\n\t\tAfter:  after,\n\t\tCommon: Common{\n\t\t\tEventType: event,\n\t\t\tRepository: Repository{\n\t\t\t\tID:          repo.ID(),\n\t\t\t\tName:        repo.Name(),\n\t\t\t\tDescription: repo.Description(),\n\t\t\t\tProjectName: repo.ProjectName(),\n\t\t\t\tPrivate:     repo.IsPrivate(),\n\t\t\t\tCreatedAt:   repo.CreatedAt(),\n\t\t\t\tUpdatedAt:   repo.UpdatedAt(),\n\t\t\t},\n\t\t\tSender: User{\n\t\t\t\tID:       user.ID(),\n\t\t\t\tUsername: user.Username(),\n\t\t\t},\n\t\t},\n\t}\n\n\tcfg := config.FromContext(ctx)\n\tpayload.Repository.HTTPURL = repoURL(cfg.HTTP.PublicURL, repo.Name())\n\tpayload.Repository.SSHURL = repoURL(cfg.SSH.PublicURL, repo.Name())\n\tpayload.Repository.GitURL = repoURL(cfg.Git.PublicURL, repo.Name())\n\n\t// Find repo owner.\n\tdbx := db.FromContext(ctx)\n\tdatastore := store.FromContext(ctx)\n\towner, err := datastore.GetUserByID(ctx, dbx, repo.UserID())\n\tif err != nil {\n\t\treturn PushEvent{}, db.WrapError(err)\n\t}\n\n\tpayload.Repository.Owner.ID = owner.ID\n\tpayload.Repository.Owner.Username = owner.Username\n\n\t// Find commits.\n\tr, err := repo.Open()\n\tif err != nil {\n\t\treturn PushEvent{}, err\n\t}\n\n\tpayload.Repository.DefaultBranch, _ = getDefaultBranch(repo)\n\n\trev := after\n\tif !git.IsZeroHash(before) {\n\t\trev = fmt.Sprintf(\"%s..%s\", before, after)\n\t}\n\n\tcommits, err := r.Log(rev, gitm.LogOptions{\n\t\t// XXX: limit to 20 commits for now\n\t\t// TODO: implement a commits api\n\t\tMaxCount: 20,\n\t})\n\tif err != nil {\n\t\treturn PushEvent{}, err\n\t}\n\n\tpayload.Commits = make([]Commit, len(commits))\n\tfor i, c := range commits {\n\t\tpayload.Commits[i] = Commit{\n\t\t\tID:      c.ID.String(),\n\t\t\tMessage: c.Message,\n\t\t\tTitle:   c.Summary(),\n\t\t\tAuthor: Author{\n\t\t\t\tName:  c.Author.Name,\n\t\t\t\tEmail: c.Author.Email,\n\t\t\t\tDate:  c.Author.When,\n\t\t\t},\n\t\t\tCommitter: Author{\n\t\t\t\tName:  c.Committer.Name,\n\t\t\t\tEmail: c.Committer.Email,\n\t\t\t\tDate:  c.Committer.When,\n\t\t\t},\n\t\t\tTimestamp: c.Committer.When,\n\t\t}\n\t}\n\n\treturn payload, nil\n}\n"
  },
  {
    "path": "pkg/webhook/repository.go",
    "content": "package webhook\n\nimport (\n\t\"context\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/store\"\n)\n\n// RepositoryEvent is a repository payload.\ntype RepositoryEvent struct {\n\tCommon\n\n\t// Action is the repository event action.\n\tAction RepositoryEventAction `json:\"action\" url:\"action\"`\n}\n\n// RepositoryEventAction is a repository event action.\ntype RepositoryEventAction string\n\nconst (\n\t// RepositoryEventActionDelete is a repository deleted event.\n\tRepositoryEventActionDelete RepositoryEventAction = \"delete\"\n\t// RepositoryEventActionRename is a repository renamed event.\n\tRepositoryEventActionRename RepositoryEventAction = \"rename\"\n\t// RepositoryEventActionVisibilityChange is a repository visibility changed event.\n\tRepositoryEventActionVisibilityChange RepositoryEventAction = \"visibility_change\"\n\t// RepositoryEventActionDefaultBranchChange is a repository default branch changed event.\n\tRepositoryEventActionDefaultBranchChange RepositoryEventAction = \"default_branch_change\"\n)\n\n// NewRepositoryEvent sends a repository event.\nfunc NewRepositoryEvent(ctx context.Context, user proto.User, repo proto.Repository, action RepositoryEventAction) (RepositoryEvent, error) {\n\tvar event Event\n\tswitch action {\n\tcase RepositoryEventActionVisibilityChange:\n\t\tevent = EventRepositoryVisibilityChange\n\tdefault:\n\t\tevent = EventRepository\n\t}\n\n\tpayload := RepositoryEvent{\n\t\tAction: action,\n\t\tCommon: Common{\n\t\t\tEventType: event,\n\t\t\tRepository: Repository{\n\t\t\t\tID:          repo.ID(),\n\t\t\t\tName:        repo.Name(),\n\t\t\t\tDescription: repo.Description(),\n\t\t\t\tProjectName: repo.ProjectName(),\n\t\t\t\tPrivate:     repo.IsPrivate(),\n\t\t\t\tCreatedAt:   repo.CreatedAt(),\n\t\t\t\tUpdatedAt:   repo.UpdatedAt(),\n\t\t\t},\n\t\t\tSender: User{\n\t\t\t\tID:       user.ID(),\n\t\t\t\tUsername: user.Username(),\n\t\t\t},\n\t\t},\n\t}\n\n\tcfg := config.FromContext(ctx)\n\tpayload.Repository.HTTPURL = repoURL(cfg.HTTP.PublicURL, repo.Name())\n\tpayload.Repository.SSHURL = repoURL(cfg.SSH.PublicURL, repo.Name())\n\tpayload.Repository.GitURL = repoURL(cfg.Git.PublicURL, repo.Name())\n\n\t// Find repo owner.\n\tdbx := db.FromContext(ctx)\n\tdatastore := store.FromContext(ctx)\n\towner, err := datastore.GetUserByID(ctx, dbx, repo.UserID())\n\tif err != nil {\n\t\treturn RepositoryEvent{}, db.WrapError(err)\n\t}\n\n\tpayload.Repository.Owner.ID = owner.ID\n\tpayload.Repository.Owner.Username = owner.Username\n\tpayload.Repository.DefaultBranch, _ = getDefaultBranch(repo)\n\n\treturn payload, nil\n}\n"
  },
  {
    "path": "pkg/webhook/ssrf_test.go",
    "content": "package webhook\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net/http\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/db/models\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ssrf\"\n)\n\n// TestSSRFProtection is an integration test verifying the webhook send path\n// blocks private IPs end-to-end (models.Webhook -> secureHTTPClient -> ssrf).\nfunc TestSSRFProtection(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\twebhookURL  string\n\t\tshouldBlock bool\n\t}{\n\t\t{\"block loopback\", \"http://127.0.0.1:8080/webhook\", true},\n\t\t{\"block metadata\", \"http://169.254.169.254/latest/meta-data/\", true},\n\t\t{\"allow public IP\", \"http://8.8.8.8/webhook\", false},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tw := models.Webhook{\n\t\t\t\tURL:         tt.webhookURL,\n\t\t\t\tContentType: int(ContentTypeJSON),\n\t\t\t}\n\n\t\t\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\t\t\tdefer cancel()\n\n\t\t\treq, err := http.NewRequestWithContext(ctx, \"POST\", w.URL, nil)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"failed to create request: %v\", err)\n\t\t\t}\n\n\t\t\tresp, err := secureHTTPClient.Do(req)\n\t\t\tif resp != nil {\n\t\t\t\tresp.Body.Close()\n\t\t\t}\n\n\t\t\tif tt.shouldBlock {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Errorf(\"%s: expected error but got none\", tt.name)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err != nil && errors.Is(err, ssrf.ErrPrivateIP) {\n\t\t\t\t\tt.Errorf(\"%s: should not block public IPs, got: %v\", tt.name, err)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/webhook/validator.go",
    "content": "package webhook\n\nimport (\n\t\"github.com/charmbracelet/soft-serve/pkg/ssrf\"\n)\n\n// Error aliases for backward compatibility.\nvar (\n\tErrInvalidScheme = ssrf.ErrInvalidScheme\n\tErrPrivateIP     = ssrf.ErrPrivateIP\n\tErrInvalidURL    = ssrf.ErrInvalidURL\n)\n\n// ValidateWebhookURL validates that a webhook URL is safe to use.\nfunc ValidateWebhookURL(rawURL string) error {\n\treturn ssrf.ValidateURL(rawURL) //nolint:wrapcheck\n}\n\n// ValidateIPBeforeDial validates an IP address before establishing a connection.\nvar ValidateIPBeforeDial = ssrf.ValidateIPBeforeDial\n"
  },
  {
    "path": "pkg/webhook/validator_test.go",
    "content": "package webhook\n\nimport (\n\t\"errors\"\n\t\"testing\"\n\n\t\"github.com/charmbracelet/soft-serve/pkg/ssrf\"\n)\n\n// TestValidateWebhookURL verifies the wrapper delegates correctly and\n// error aliases work across the package boundary. IP range coverage\n// is in pkg/ssrf/ssrf_test.go -- here we just confirm the plumbing.\nfunc TestValidateWebhookURL(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\turl     string\n\t\twantErr bool\n\t\terrType error\n\t}{\n\t\t{\"valid\", \"https://1.1.1.1/webhook\", false, nil},\n\t\t{\"bad scheme\", \"ftp://example.com\", true, ErrInvalidScheme},\n\t\t{\"private IP\", \"http://127.0.0.1/webhook\", true, ErrPrivateIP},\n\t\t{\"empty\", \"\", true, ErrInvalidURL},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\terr := ValidateWebhookURL(tt.url)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"ValidateWebhookURL(%q) error = %v, wantErr %v\", tt.url, err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif tt.wantErr && tt.errType != nil {\n\t\t\t\tif !errors.Is(err, tt.errType) {\n\t\t\t\t\tt.Errorf(\"ValidateWebhookURL(%q) error = %v, want %v\", tt.url, err, tt.errType)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestErrorAliases(t *testing.T) {\n\tif ErrPrivateIP != ssrf.ErrPrivateIP {\n\t\tt.Error(\"ErrPrivateIP should alias ssrf.ErrPrivateIP\")\n\t}\n\tif ErrInvalidScheme != ssrf.ErrInvalidScheme {\n\t\tt.Error(\"ErrInvalidScheme should alias ssrf.ErrInvalidScheme\")\n\t}\n\tif ErrInvalidURL != ssrf.ErrInvalidURL {\n\t\tt.Error(\"ErrInvalidURL should alias ssrf.ErrInvalidURL\")\n\t}\n}\n"
  },
  {
    "path": "pkg/webhook/webhook.go",
    "content": "package webhook\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/hmac\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/charmbracelet/soft-serve/git\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db/models\"\n\t\"github.com/charmbracelet/soft-serve/pkg/proto\"\n\t\"github.com/charmbracelet/soft-serve/pkg/ssrf\"\n\t\"github.com/charmbracelet/soft-serve/pkg/store\"\n\t\"github.com/charmbracelet/soft-serve/pkg/utils\"\n\t\"github.com/charmbracelet/soft-serve/pkg/version\"\n\t\"github.com/google/go-querystring/query\"\n\t\"github.com/google/uuid\"\n)\n\n// Hook is a repository webhook.\ntype Hook struct {\n\tmodels.Webhook\n\tContentType ContentType\n\tEvents      []Event\n}\n\n// Delivery is a webhook delivery.\ntype Delivery struct {\n\tmodels.WebhookDelivery\n\tEvent Event\n}\n\n// secureHTTPClient is an HTTP client with SSRF protection.\nvar secureHTTPClient = ssrf.NewSecureClient()\n\n// do sends a webhook.\n// Caller must close the returned body.\nfunc do(ctx context.Context, url string, method string, headers http.Header, body io.Reader) (*http.Response, error) {\n\treq, err := http.NewRequestWithContext(ctx, method, url, body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treq.Header = headers\n\tres, err := secureHTTPClient.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn res, nil\n}\n\n// SendWebhook sends a webhook event.\nfunc SendWebhook(ctx context.Context, w models.Webhook, event Event, payload interface{}) error {\n\tvar buf bytes.Buffer\n\tdbx := db.FromContext(ctx)\n\tdatastore := store.FromContext(ctx)\n\n\tcontentType := ContentType(w.ContentType) //nolint:gosec\n\tswitch contentType {\n\tcase ContentTypeJSON:\n\t\tif err := json.NewEncoder(&buf).Encode(payload); err != nil {\n\t\t\treturn err\n\t\t}\n\tcase ContentTypeForm:\n\t\tv, err := query.Values(payload)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tbuf.WriteString(v.Encode()) //nolint: errcheck\n\tdefault:\n\t\treturn ErrInvalidContentType\n\t}\n\n\theaders := http.Header{}\n\theaders.Add(\"Content-Type\", contentType.String())\n\theaders.Add(\"User-Agent\", \"SoftServe/\"+version.Version)\n\theaders.Add(\"X-SoftServe-Event\", event.String())\n\n\tid, err := uuid.NewUUID()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\theaders.Add(\"X-SoftServe-Delivery\", id.String())\n\n\treqBody := buf.String()\n\tif w.Secret != \"\" {\n\t\tsig := hmac.New(sha256.New, []byte(w.Secret))\n\t\tsig.Write([]byte(reqBody)) //nolint: errcheck\n\t\theaders.Add(\"X-SoftServe-Signature\", \"sha256=\"+hex.EncodeToString(sig.Sum(nil)))\n\t}\n\n\tres, reqErr := do(ctx, w.URL, http.MethodPost, headers, &buf)\n\tvar reqHeaders string\n\tfor k, v := range headers {\n\t\treqHeaders += k + \": \" + v[0] + \"\\n\"\n\t}\n\n\tresStatus := 0\n\tresHeaders := \"\"\n\tresBody := \"\"\n\n\tif res != nil {\n\t\tresStatus = res.StatusCode\n\t\tfor k, v := range res.Header {\n\t\t\tresHeaders += k + \": \" + v[0] + \"\\n\"\n\t\t}\n\n\t\tif res.Body != nil {\n\t\t\tdefer res.Body.Close() //nolint: errcheck\n\t\t\tb, err := io.ReadAll(res.Body)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tresBody = string(b)\n\t\t}\n\t}\n\n\treturn db.WrapError(datastore.CreateWebhookDelivery(ctx, dbx, id, w.ID, int(event), w.URL, http.MethodPost, reqErr, reqHeaders, reqBody, resStatus, resHeaders, resBody))\n}\n\n// SendEvent sends a webhook event.\nfunc SendEvent(ctx context.Context, payload EventPayload) error {\n\tdbx := db.FromContext(ctx)\n\tdatastore := store.FromContext(ctx)\n\twebhooks, err := datastore.GetWebhooksByRepoIDWhereEvent(ctx, dbx, payload.RepositoryID(), []int{int(payload.Event())})\n\tif err != nil {\n\t\treturn db.WrapError(err)\n\t}\n\n\tfor _, w := range webhooks {\n\t\tif err := SendWebhook(ctx, w, payload.Event(), payload); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc repoURL(publicURL string, repo string) string {\n\treturn fmt.Sprintf(\"%s/%s.git\", publicURL, utils.SanitizeRepo(repo))\n}\n\nfunc getDefaultBranch(repo proto.Repository) (string, error) {\n\tbranch, err := proto.RepositoryDefaultBranch(repo)\n\t// XXX: we check for ErrReferenceNotExist here because we don't want to\n\t// return an error if the repo is an empty repo.\n\t// This means that the repo doesn't have a default branch yet and this is\n\t// the first push to it.\n\tif err != nil && !errors.Is(err, git.ErrReferenceNotExist) {\n\t\treturn \"\", err\n\t}\n\n\treturn branch, nil\n}\n"
  },
  {
    "path": "systemd.md",
    "content": "# Running Soft Serve as a Systemd Service\n\nMost Linux OSes use Systemd as an init system and service management. You can\nuse Systemd to manage Soft Serve as a service on your host machine.\n\nOur Soft Serve deb/rpm packages come with Systemd service files pre-packaged.\nYou can install `soft-serve` from our Apt/Yum repositories. Follow the\n[installation instructions](https://github.com/charmbracelet/soft-serve#installation) for\nmore information.\n\n## Writing a Systemd Service File\n\n> **Note** you can skip this section if you are using our deb/rpm packages or\n> installed Soft Serve from our Apt/Yum repositories.\n\nStart by writing a Systemd service file to define how your Soft Serve server\nshould start.\n\nFirst, we need to specify where the data should live for our server. Here I\nwill be choosing `/var/local/lib/soft-serve` to store the server's data. Soft\nServe will look for this path in the `SOFT_SERVE_DATA_PATH` environment\nvariable.\n\nMake sure this directory exists before proceeding.\n\n```sh\nsudo mkdir -p /var/local/lib/soft-serve\n```\n\nWe will also create a `/etc/soft-serve.conf` file for any extra server settings that we want to override.\n\n```conf\n# Config defined here will override the config in /var/local/lib/soft-serve/config.yaml\n# Keys defined in `SOFT_SERVE_INITIAL_ADMIN_KEYS` will be merged with\n# the `initial_admin_keys` from /var/local/lib/soft-serve/config.yaml.\n#\n#SOFT_SERVE_GIT_LISTEN_ADDR=:9418\n#SOFT_SERVE_HTTP_LISTEN_ADDR=:23232\n#SOFT_SERVE_SSH_LISTEN_ADDR=:23231\n#SOFT_SERVE_SSH_KEY_PATH=ssh/soft_serve_host_ed25519\n#SOFT_SERVE_INITIAL_ADMIN_KEYS='ssh-ed25519 AAAAC3NzaC1lZDI1...'\n```\n\n> **Note** Soft Serve stores its server configuration and settings in\n> `config.yaml` under its _data path_ directory specified using\n> `SOFT_SERVE_DATA_PATH` environment variable.\n\nNow, let's write a new `/etc/systemd/system/soft-serve.service` Systemd service file:\n\n```conf\n[Unit]\nDescription=Soft Serve git server 🍦\nDocumentation=https://github.com/charmbracelet/soft-serve\nRequires=network-online.target\nAfter=network-online.target\n\n[Service]\nType=simple\nRestart=always\nRestartSec=1\nExecStart=/usr/bin/soft serve\nEnvironment=SOFT_SERVE_DATA_PATH=/var/local/lib/soft-serve\nEnvironmentFile=-/etc/soft-serve.conf\nWorkingDirectory=/var/local/lib/soft-serve\n\n[Install]\nWantedBy=multi-user.target\n```\n\nGreat, we now have a Systemd service file for Soft Serve. The settings defined\nhere may vary depending on your specific setup. This assumes that you want to\nrun Soft Serve as `root`. For more information on Systemd service files, refer\nto\n[systemd.service](https://www.freedesktop.org/software/systemd/man/systemd.service.html)\n\n## Start Soft Serve on boot\n\nNow that we have our Soft Serve Systemd service file in-place, let's go ahead\nand enable and start Soft Serve to run on-boot.\n\n```sh\n# Reload systemd daemon\nsudo systemctl daemon-reload\n# Enable Soft Serve to start on-boot\nsudo systemctl enable soft-serve.service\n# Start Soft Serve now!!\nsudo systemctl start soft-serve.service\n```\n\nYou can monitor the server logs using `journalctl -u soft-serve.service`. Use\n`-f` to _tail_ and follow the logs as they get written.\n\n***\n\nPart of [Charm](https://charm.sh).\n\n<a href=\"https://charm.sh/\"><img alt=\"The Charm logo\" src=\"https://stuff.charm.sh/charm-badge-unrounded.jpg\" width=\"400\"></a>\n\nCharm热爱开源 • Charm loves open source\n"
  },
  {
    "path": "testscript/script_test.go",
    "content": "package testscript\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"flag\"\n\t\"fmt\"\n\t\"io\"\n\t\"math/rand\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strconv\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/charmbracelet/keygen\"\n\t\"github.com/charmbracelet/soft-serve/pkg/config\"\n\t\"github.com/charmbracelet/soft-serve/pkg/db\"\n\t\"github.com/charmbracelet/soft-serve/pkg/test\"\n\t\"github.com/rogpeppe/go-internal/testscript\"\n\t\"github.com/spf13/cobra\"\n\t\"golang.org/x/crypto/ssh\"\n)\n\nvar (\n\tupdate  = flag.Bool(\"update\", false, \"update script files\")\n\tbinPath string\n)\n\nfunc PrepareBuildCommand(binPath string) *exec.Cmd {\n\t_, disableRaceSet := os.LookupEnv(\"SOFT_SERVE_DISABLE_RACE_CHECKS\")\n\tif disableRaceSet {\n\t\t// don't add the -race flag\n\t\treturn exec.Command(\"go\", \"build\", \"-cover\", \"-o\", binPath, filepath.Join(\"..\", \"cmd\", \"soft\")) //nolint:noctx\n\t}\n\treturn exec.Command(\"go\", \"build\", \"-race\", \"-cover\", \"-o\", binPath, filepath.Join(\"..\", \"cmd\", \"soft\")) //nolint:noctx\n}\n\nfunc TestMain(m *testing.M) {\n\ttmp, err := os.MkdirTemp(\"\", \"soft-serve*\")\n\tif err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"failed to create temporary directory: %s\", err)\n\t\tos.Exit(1)\n\t}\n\tdefer os.RemoveAll(tmp)\n\n\tbinPath = filepath.Join(tmp, \"soft\")\n\tif runtime.GOOS == \"windows\" {\n\t\tbinPath += \".exe\"\n\t}\n\n\t// Build the soft binary with -cover flag.\n\tcmd := PrepareBuildCommand(binPath)\n\tif err := cmd.Run(); err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"failed to build soft-serve binary: %s\", err)\n\t\tos.Exit(1)\n\t}\n\n\t// Run tests\n\tos.Exit(m.Run())\n}\n\nfunc TestScript(t *testing.T) {\n\tflag.Parse()\n\n\tmkkey := func(name string) (string, *keygen.SSHKeyPair) {\n\t\tpath := filepath.Join(t.TempDir(), name)\n\t\tpair, err := keygen.New(path, keygen.WithKeyType(keygen.Ed25519), keygen.WithWrite())\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\treturn path, pair\n\t}\n\n\tadmin1Key, admin1 := mkkey(\"admin1\")\n\t_, admin2 := mkkey(\"admin2\")\n\tuser1Key, user1 := mkkey(\"user1\")\n\tattackerKey, attacker := mkkey(\"attacker\")\n\tattackerSigner := &maliciousSigner{\n\t\tpublicKey: admin1.PublicKey(),\n\t}\n\n\ttestscript.Run(t, testscript.Params{\n\t\tDir:                 \"./testdata/\",\n\t\tUpdateScripts:       *update,\n\t\tRequireExplicitExec: true,\n\t\tCmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){\n\t\t\t\"soft\":                   cmdSoft(\"admin\", admin1.Signer()),\n\t\t\t\"usoft\":                  cmdSoft(\"user1\", user1.Signer()),\n\t\t\t\"attacksoft\":             cmdSoft(\"attacker\", attackerSigner, attacker.Signer()),\n\t\t\t\"git\":                    cmdGit(admin1Key),\n\t\t\t\"ugit\":                   cmdGit(user1Key),\n\t\t\t\"agit\":                   cmdGit(attackerKey),\n\t\t\t\"curl\":                   cmdCurl,\n\t\t\t\"mkfile\":                 cmdMkfile,\n\t\t\t\"envfile\":                cmdEnvfile,\n\t\t\t\"readfile\":               cmdReadfile,\n\t\t\t\"dos2unix\":               cmdDos2Unix,\n\t\t\t\"new-webhook\":            cmdNewWebhook,\n\t\t\t\"ensureserverrunning\":    cmdEnsureServerRunning,\n\t\t\t\"ensureservernotrunning\": cmdEnsureServerNotRunning,\n\t\t\t\"stopserver\":             cmdStopserver,\n\t\t\t\"ui\":                     cmdUI(admin1.Signer()),\n\t\t\t\"uui\":                    cmdUI(user1.Signer()),\n\t\t},\n\t\tSetup: func(e *testscript.Env) error {\n\t\t\t// Add binPath to PATH\n\t\t\te.Setenv(\"PATH\", fmt.Sprintf(\"%s%c%s\", filepath.Dir(binPath), os.PathListSeparator, e.Getenv(\"PATH\")))\n\n\t\t\tdata := t.TempDir()\n\t\t\tsshPort := test.RandomPort()\n\t\t\tsshListen := fmt.Sprintf(\"localhost:%d\", sshPort)\n\t\t\tgitPort := test.RandomPort()\n\t\t\tgitListen := fmt.Sprintf(\"localhost:%d\", gitPort)\n\t\t\thttpPort := test.RandomPort()\n\t\t\thttpListen := fmt.Sprintf(\"localhost:%d\", httpPort)\n\t\t\tstatsPort := test.RandomPort()\n\t\t\tstatsListen := fmt.Sprintf(\"localhost:%d\", statsPort)\n\t\t\tserverName := \"Test Soft Serve\"\n\n\t\t\te.Setenv(\"DATA_PATH\", data)\n\t\t\te.Setenv(\"SSH_PORT\", fmt.Sprintf(\"%d\", sshPort))\n\t\t\te.Setenv(\"HTTP_PORT\", fmt.Sprintf(\"%d\", httpPort))\n\t\t\te.Setenv(\"STATS_PORT\", fmt.Sprintf(\"%d\", statsPort))\n\t\t\te.Setenv(\"GIT_PORT\", fmt.Sprintf(\"%d\", gitPort))\n\t\t\te.Setenv(\"ADMIN1_AUTHORIZED_KEY\", admin1.AuthorizedKey())\n\t\t\te.Setenv(\"ADMIN2_AUTHORIZED_KEY\", admin2.AuthorizedKey())\n\t\t\te.Setenv(\"USER1_AUTHORIZED_KEY\", user1.AuthorizedKey())\n\t\t\te.Setenv(\"ATTACKER_AUTHORIZED_KEY\", attacker.AuthorizedKey())\n\t\t\te.Setenv(\"SSH_KNOWN_HOSTS_FILE\", filepath.Join(t.TempDir(), \"known_hosts\"))\n\t\t\te.Setenv(\"SSH_KNOWN_CONFIG_FILE\", filepath.Join(t.TempDir(), \"config\"))\n\n\t\t\t// This is used to set up test specific configuration and http endpoints\n\t\t\te.Setenv(\"SOFT_SERVE_TESTRUN\", \"1\")\n\n\t\t\t// This will disable the default lipgloss renderer colors\n\t\t\te.Setenv(\"SOFT_SERVE_NO_COLOR\", \"1\")\n\n\t\t\t// Soft Serve debug environment variables\n\t\t\tfor _, env := range []string{\n\t\t\t\t\"SOFT_SERVE_DEBUG\",\n\t\t\t\t\"SOFT_SERVE_VERBOSE\",\n\t\t\t} {\n\t\t\t\tif v, ok := os.LookupEnv(env); ok {\n\t\t\t\t\te.Setenv(env, v)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// TODO: test different configs\n\t\t\tcfg := config.DefaultConfig()\n\t\t\tcfg.DataPath = data\n\t\t\tcfg.Name = serverName\n\t\t\tcfg.InitialAdminKeys = []string{admin1.AuthorizedKey()}\n\t\t\tcfg.SSH.ListenAddr = sshListen\n\t\t\tcfg.SSH.PublicURL = \"ssh://\" + sshListen\n\t\t\tcfg.Git.ListenAddr = gitListen\n\t\t\tcfg.HTTP.ListenAddr = httpListen\n\t\t\tcfg.HTTP.PublicURL = \"http://\" + httpListen\n\t\t\tcfg.Stats.ListenAddr = statsListen\n\t\t\tcfg.LFS.Enabled = true\n\n\t\t\t// Parse os SOFT_SERVE environment variables\n\t\t\tif err := cfg.ParseEnv(); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// Override the database data source if we're using postgres\n\t\t\t// so we can create a temporary database for the tests.\n\t\t\tif cfg.DB.Driver == \"postgres\" {\n\t\t\t\tcleanup, err := setupPostgres(e.T(), cfg)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tif cleanup != nil {\n\t\t\t\t\te.Defer(cleanup)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tfor _, env := range cfg.Environ() {\n\t\t\t\tparts := strings.SplitN(env, \"=\", 2)\n\t\t\t\tif len(parts) != 2 {\n\t\t\t\t\te.T().Fatal(\"invalid environment variable\", env)\n\t\t\t\t}\n\t\t\t\te.Setenv(parts[0], parts[1])\n\t\t\t}\n\n\t\t\treturn nil\n\t\t},\n\t})\n}\n\nfunc cmdSoft(user string, keys ...ssh.Signer) func(ts *testscript.TestScript, neg bool, args []string) {\n\treturn func(ts *testscript.TestScript, neg bool, args []string) {\n\t\tcli, err := ssh.Dial(\n\t\t\t\"tcp\",\n\t\t\tnet.JoinHostPort(\"localhost\", ts.Getenv(\"SSH_PORT\")),\n\t\t\t&ssh.ClientConfig{\n\t\t\t\tUser:            user,\n\t\t\t\tAuth:            []ssh.AuthMethod{ssh.PublicKeys(keys...)},\n\t\t\t\tHostKeyCallback: ssh.InsecureIgnoreHostKey(),\n\t\t\t},\n\t\t)\n\t\tts.Check(err)\n\t\tdefer cli.Close()\n\n\t\tsess, err := cli.NewSession()\n\t\tts.Check(err)\n\t\tdefer sess.Close()\n\n\t\tsess.Stdout = ts.Stdout()\n\t\tsess.Stderr = ts.Stderr()\n\n\t\tcheck(ts, sess.Run(strings.Join(args, \" \")), neg)\n\t}\n}\n\nfunc cmdUI(key ssh.Signer) func(ts *testscript.TestScript, neg bool, args []string) {\n\treturn func(ts *testscript.TestScript, neg bool, args []string) {\n\t\tif len(args) < 1 {\n\t\t\tts.Fatalf(\"usage: ui <quoted string input>\")\n\t\t\treturn\n\t\t}\n\n\t\tcli, err := ssh.Dial(\n\t\t\t\"tcp\",\n\t\t\tnet.JoinHostPort(\"localhost\", ts.Getenv(\"SSH_PORT\")),\n\t\t\t&ssh.ClientConfig{\n\t\t\t\tUser:            \"git\",\n\t\t\t\tAuth:            []ssh.AuthMethod{ssh.PublicKeys(key)},\n\t\t\t\tHostKeyCallback: ssh.InsecureIgnoreHostKey(),\n\t\t\t},\n\t\t)\n\t\tcheck(ts, err, neg)\n\t\tdefer cli.Close()\n\n\t\tsess, err := cli.NewSession()\n\t\tcheck(ts, err, neg)\n\t\tdefer sess.Close()\n\n\t\t// XXX: this is a hack to make the UI tests work\n\t\t// cmp command always complains about an extra newline\n\t\t// in the output\n\t\tdefer ts.Stdout().Write([]byte(\"\\n\"))\n\n\t\tsess.Stdout = ts.Stdout()\n\t\tsess.Stderr = ts.Stderr()\n\n\t\tstdin, err := sess.StdinPipe()\n\t\tcheck(ts, err, neg)\n\n\t\terr = sess.RequestPty(\"dumb\", 40, 80, ssh.TerminalModes{})\n\t\tcheck(ts, err, neg)\n\t\tcheck(ts, sess.Start(\"\"), neg)\n\n\t\tin, err := strconv.Unquote(args[0])\n\t\tcheck(ts, err, neg)\n\t\treader := strings.NewReader(in)\n\t\tgo func() {\n\t\t\tdefer stdin.Close()\n\t\t\tfor {\n\t\t\t\tr, _, err := reader.ReadRune()\n\t\t\t\tif err == io.EOF {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tcheck(ts, err, neg)\n\t\t\t\t_, _ = io.WriteString(stdin, string(r))\n\n\t\t\t\t// Wait for the UI to process the input\n\t\t\t\ttime.Sleep(100 * time.Millisecond)\n\t\t\t}\n\t\t}()\n\n\t\tcheck(ts, sess.Wait(), neg)\n\t}\n}\n\nfunc cmdDos2Unix(ts *testscript.TestScript, neg bool, args []string) {\n\tif neg {\n\t\tts.Fatalf(\"unsupported: ! dos2unix\")\n\t}\n\tif len(args) < 1 {\n\t\tts.Fatalf(\"usage: dos2unix paths...\")\n\t}\n\tfor _, arg := range args {\n\t\tfilename := ts.MkAbs(arg)\n\t\tdata, err := os.ReadFile(filename)\n\t\tif err != nil {\n\t\t\tts.Fatalf(\"%s: %v\", filename, err)\n\t\t}\n\n\t\t// Replace all '\\r\\n' with '\\n'.\n\t\tdata = bytes.ReplaceAll(data, []byte{'\\r', '\\n'}, []byte{'\\n'})\n\n\t\tif err := os.WriteFile(filename, data, 0o644); err != nil {\n\t\t\tts.Fatalf(\"%s: %v\", filename, err)\n\t\t}\n\t}\n}\n\nvar sshConfig = `\nHost *\n  UserKnownHostsFile %q\n  StrictHostKeyChecking no\n  IdentityAgent none\n  IdentitiesOnly yes\n  ServerAliveInterval 60\n`\n\nfunc cmdGit(key string) func(ts *testscript.TestScript, neg bool, args []string) {\n\treturn func(ts *testscript.TestScript, neg bool, args []string) {\n\t\tts.Check(os.WriteFile(\n\t\t\tts.Getenv(\"SSH_KNOWN_CONFIG_FILE\"),\n\t\t\t[]byte(fmt.Sprintf(sshConfig, ts.Getenv(\"SSH_KNOWN_HOSTS_FILE\"))),\n\t\t\t0o600,\n\t\t))\n\t\tsshArgs := []string{\n\t\t\t\"-F\", filepath.ToSlash(ts.Getenv(\"SSH_KNOWN_CONFIG_FILE\")),\n\t\t\t\"-i\", filepath.ToSlash(key),\n\t\t}\n\t\tts.Setenv(\n\t\t\t\"GIT_SSH_COMMAND\",\n\t\t\tstrings.Join(append([]string{\"ssh\"}, sshArgs...), \" \"),\n\t\t)\n\t\t// Disable git prompting for credentials.\n\t\tts.Setenv(\"GIT_TERMINAL_PROMPT\", \"0\")\n\t\targs = append([]string{\n\t\t\t\"-c\", \"user.email=john@example.com\",\n\t\t\t\"-c\", \"user.name=John Doe\",\n\t\t}, args...)\n\t\tcheck(ts, ts.Exec(\"git\", args...), neg)\n\t}\n}\n\nfunc cmdMkfile(ts *testscript.TestScript, neg bool, args []string) {\n\tif len(args) < 2 {\n\t\tts.Fatalf(\"usage: mkfile path content\")\n\t}\n\tcheck(ts, os.WriteFile(\n\t\tts.MkAbs(args[0]),\n\t\t[]byte(strings.Join(args[1:], \" \")),\n\t\t0o644,\n\t), neg)\n}\n\nfunc check(ts *testscript.TestScript, err error, neg bool) {\n\tif neg && err == nil {\n\t\tts.Fatalf(\"expected error, got nil\")\n\t}\n\tif !neg {\n\t\tts.Check(err)\n\t}\n}\n\nfunc cmdReadfile(ts *testscript.TestScript, neg bool, args []string) {\n\tts.Stdout().Write([]byte(ts.ReadFile(args[0])))\n}\n\nfunc cmdEnvfile(ts *testscript.TestScript, neg bool, args []string) {\n\tif len(args) < 1 {\n\t\tts.Fatalf(\"usage: envfile key=file...\")\n\t}\n\n\tfor _, arg := range args {\n\t\tparts := strings.SplitN(arg, \"=\", 2)\n\t\tif len(parts) != 2 {\n\t\t\tts.Fatalf(\"usage: envfile key=file...\")\n\t\t}\n\t\tkey := parts[0]\n\t\tfile := parts[1]\n\t\tts.Setenv(key, strings.TrimSpace(ts.ReadFile(file)))\n\t}\n}\n\nfunc cmdNewWebhook(ts *testscript.TestScript, neg bool, args []string) {\n\ttype webhookSite struct {\n\t\tUUID string `json:\"uuid\"`\n\t}\n\n\tif len(args) != 1 {\n\t\tts.Fatalf(\"usage: new-webhook <env-name>\")\n\t}\n\n\tconst whSite = \"https://webhook.site\"\n\treq, err := http.NewRequest(http.MethodPost, whSite+\"/token\", nil) //nolint:noctx\n\tcheck(ts, err, neg)\n\n\tresp, err := http.DefaultClient.Do(req)\n\tcheck(ts, err, neg)\n\n\tdefer resp.Body.Close()\n\tvar site webhookSite\n\tcheck(ts, json.NewDecoder(resp.Body).Decode(&site), neg)\n\n\tts.Setenv(args[0], whSite+\"/\"+site.UUID)\n}\n\nfunc cmdCurl(ts *testscript.TestScript, neg bool, args []string) {\n\tvar verbose bool\n\tvar headers []string\n\tvar data string\n\tmethod := http.MethodGet\n\n\tcmd := &cobra.Command{\n\t\tUse:  \"curl\",\n\t\tArgs: cobra.MinimumNArgs(1),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\turl, err := url.Parse(args[0])\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\treq, err := http.NewRequest(method, url.String(), nil) //nolint:noctx\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif data != \"\" {\n\t\t\t\treq.Body = io.NopCloser(strings.NewReader(data))\n\t\t\t}\n\n\t\t\tif verbose {\n\t\t\t\tfmt.Fprintf(cmd.ErrOrStderr(), \"< %s %s\\n\", req.Method, url.String())\n\t\t\t}\n\n\t\t\tfor _, header := range headers {\n\t\t\t\tparts := strings.SplitN(header, \":\", 2)\n\t\t\t\tif len(parts) != 2 {\n\t\t\t\t\treturn fmt.Errorf(\"invalid header: %s\", header)\n\t\t\t\t}\n\t\t\t\treq.Header.Add(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]))\n\t\t\t}\n\n\t\t\tif userInfo := url.User; userInfo != nil {\n\t\t\t\tpassword, _ := userInfo.Password()\n\t\t\t\treq.SetBasicAuth(userInfo.Username(), password)\n\t\t\t}\n\n\t\t\tif verbose {\n\t\t\t\tfor key, values := range req.Header {\n\t\t\t\t\tfor _, value := range values {\n\t\t\t\t\t\tfmt.Fprintf(cmd.ErrOrStderr(), \"< %s: %s\\n\", key, value)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tresp, err := http.DefaultClient.Do(req)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif verbose {\n\t\t\t\tfmt.Fprintf(ts.Stderr(), \"> %s\\n\", resp.Status)\n\t\t\t\tfor key, values := range resp.Header {\n\t\t\t\t\tfor _, value := range values {\n\t\t\t\t\t\tfmt.Fprintf(cmd.ErrOrStderr(), \"> %s: %s\\n\", key, value)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tdefer resp.Body.Close()\n\t\t\tbuf, err := io.ReadAll(resp.Body)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tcmd.Print(string(buf))\n\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tcmd.SetArgs(args)\n\tcmd.SetOut(ts.Stdout())\n\tcmd.SetErr(ts.Stderr())\n\n\tcmd.Flags().BoolVarP(&verbose, \"verbose\", \"v\", verbose, \"verbose\")\n\tcmd.Flags().StringArrayVarP(&headers, \"header\", \"H\", nil, \"HTTP header\")\n\tcmd.Flags().StringVarP(&method, \"request\", \"X\", method, \"HTTP method\")\n\tcmd.Flags().StringVarP(&data, \"data\", \"d\", data, \"HTTP data\")\n\n\tcheck(ts, cmd.Execute(), neg)\n}\n\nfunc cmdEnsureServerRunning(ts *testscript.TestScript, neg bool, args []string) {\n\tif len(args) < 1 {\n\t\tts.Fatalf(\"Must supply a TCP port of one of the services to connect to. \" +\n\t\t\t\"These are set as env vars as they are randomized. \" +\n\t\t\t\"Example usage: \\\"cmdensureserverrunning SSH_PORT\\\"\\n\" +\n\t\t\t\"Valid values for the env var: SSH_PORT|HTTP_PORT|GIT_PORT|STATS_PORT\")\n\t}\n\n\tport := ts.Getenv(args[0])\n\n\t// verify that the server is up\n\taddr := net.JoinHostPort(\"localhost\", port)\n\tfor {\n\t\tconn, _ := net.DialTimeout( //nolint:noctx\n\t\t\t\"tcp\",\n\t\t\taddr,\n\t\t\ttime.Second,\n\t\t)\n\t\tif conn != nil {\n\t\t\tts.Logf(\"Server is running on port: %s\", port)\n\t\t\tconn.Close()\n\t\t\tbreak\n\t\t}\n\t}\n}\n\nfunc cmdEnsureServerNotRunning(ts *testscript.TestScript, neg bool, args []string) {\n\tif len(args) < 1 {\n\t\tts.Fatalf(\"Must supply a TCP port of one of the services to connect to. \" +\n\t\t\t\"These are set as env vars as they are randomized. \" +\n\t\t\t\"Example usage: \\\"cmdensureservernotrunning SSH_PORT\\\"\\n\" +\n\t\t\t\"Valid values for the env var: SSH_PORT|HTTP_PORT|GIT_PORT|STATS_PORT\")\n\t}\n\n\tport := ts.Getenv(args[0])\n\n\t// verify that the server is not up\n\taddr := net.JoinHostPort(\"localhost\", port)\n\tconn, _ := net.DialTimeout( //nolint:noctx\n\t\t\"tcp\",\n\t\taddr,\n\t\ttime.Second,\n\t)\n\tif conn != nil {\n\t\tts.Fatalf(\"server is running on port %s while it should not be running\", port)\n\t\tconn.Close()\n\t}\n}\n\nfunc cmdStopserver(ts *testscript.TestScript, neg bool, args []string) {\n\t// stop the server\n\tresp, err := http.DefaultClient.Head(fmt.Sprintf(\"%s/__stop\", ts.Getenv(\"SOFT_SERVE_HTTP_PUBLIC_URL\"))) //nolint:noctx\n\tcheck(ts, err, neg)\n\tresp.Body.Close()\n\ttime.Sleep(time.Second * 2) // Allow some time for the server to stop\n}\n\nfunc setupPostgres(t testscript.T, cfg *config.Config) (func(), error) {\n\t// Indicates postgres\n\t// Create a disposable database\n\trnd := rand.New(rand.NewSource(time.Now().UnixNano()))\n\tdbName := fmt.Sprintf(\"softserve_test_%d\", rnd.Int63())\n\tdbDsn := cfg.DB.DataSource\n\tif dbDsn == \"\" {\n\t\tcfg.DB.DataSource = \"postgres://postgres@localhost:5432/postgres?sslmode=disable\"\n\t}\n\n\tdbUrl, err := url.Parse(cfg.DB.DataSource)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tscheme := dbUrl.Scheme\n\tif scheme == \"\" {\n\t\tscheme = \"postgres\"\n\t}\n\n\thost := dbUrl.Hostname()\n\tif host == \"\" {\n\t\thost = \"localhost\"\n\t}\n\n\tconnInfo := fmt.Sprintf(\"host=%s sslmode=disable\", host)\n\tusername := dbUrl.User.Username()\n\tif username != \"\" {\n\t\tconnInfo += fmt.Sprintf(\" user=%s\", username)\n\t\tpassword, ok := dbUrl.User.Password()\n\t\tif ok {\n\t\t\tusername = fmt.Sprintf(\"%s:%s\", username, password)\n\t\t\tconnInfo += fmt.Sprintf(\" password=%s\", password)\n\t\t}\n\t\tusername = fmt.Sprintf(\"%s@\", username)\n\t} else {\n\t\tconnInfo += \" user=postgres\"\n\t\tusername = \"postgres@\"\n\t}\n\n\tport := dbUrl.Port()\n\tif port != \"\" {\n\t\tconnInfo += fmt.Sprintf(\" port=%s\", port)\n\t\tport = fmt.Sprintf(\":%s\", port)\n\t}\n\n\tcfg.DB.DataSource = fmt.Sprintf(\"%s://%s%s%s/%s?sslmode=disable\",\n\t\tscheme,\n\t\tusername,\n\t\thost,\n\t\tport,\n\t\tdbName,\n\t)\n\n\t// Create the database\n\tdbx, err := db.Open(context.TODO(), cfg.DB.Driver, connInfo)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif _, err := dbx.ExecContext(context.TODO(), \"CREATE DATABASE \"+dbName); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn func() {\n\t\tdbx, err := db.Open(context.TODO(), cfg.DB.Driver, connInfo)\n\t\tif err != nil {\n\t\t\tt.Fatal(\"failed to open database\", dbName, err)\n\t\t}\n\n\t\tif _, err := dbx.ExecContext(context.TODO(), \"DROP DATABASE \"+dbName); err != nil {\n\t\t\tt.Fatal(\"failed to drop database\", dbName, err)\n\t\t}\n\t}, nil\n}\n\ntype maliciousSigner struct {\n\tpublicKey ssh.PublicKey\n}\n\nvar _ ssh.Signer = (*maliciousSigner)(nil)\n\n// PublicKey implements ssh.Signer.\nfunc (m *maliciousSigner) PublicKey() ssh.PublicKey {\n\treturn m.publicKey\n}\n\n// Sign implements ssh.Signer.\nfunc (m *maliciousSigner) Sign(rand io.Reader, data []byte) (*ssh.Signature, error) {\n\t// The attacker doesn't know how to sign the data without a private key.\n\treturn &ssh.Signature{}, nil\n}\n"
  },
  {
    "path": "testscript/testdata/anon-access.txtar",
    "content": "# vi: set ft=conf\n\n# start soft serve\nexec soft serve &\n# wait for SSH server to start\nensureserverrunning SSH_PORT\n\n# set settings\nsoft settings allow-keyless true\nsoft settings anon-access no-access\n\n# create a repo\nsoft repo create repo1\ngit clone ssh://localhost:$SSH_PORT/repo1 repo1\nmkfile ./repo1/README.md '# Hello\\n\\nwelcome'\ngit -C repo1 add -A\ngit -C repo1 commit -m 'first'\ngit -C repo1 push origin HEAD\n\n# access repo from anon\n! ugit clone ssh://localhost:$SSH_PORT/repo1 urepo1\nstderr 'Error: you are not authorized to do this'\n\n# list repo as anon\nusoft repo list\nstdout ''\n\n# create repo as anon\n! usoft repo create urepo2\nstderr 'Error: unauthorized'\n\n# stop the server\n[windows] stopserver\n"
  },
  {
    "path": "testscript/testdata/auth-bypass-regression.txtar",
    "content": "# vi: set ft=conf\n# Regression test for authentication bypass vulnerability\n#\n# VULNERABILITY DESCRIPTION:\n# A critical authentication bypass allows an attacker to impersonate any user\n# (including Admin) by offering the user's public key but failing to sign with\n# it, then successfully authenticating with their own key.\n#\n# ATTACK SCENARIO:\n# 1. Attacker obtains Admin's public key (publicly available)\n# 2. Attacker configures SSH client to offer TWO keys in sequence:\n#    - First: Admin's public key (attacker has this but not the private key)\n#    - Second: Attacker's own valid key pair\n# 3. During SSH handshake:\n#    - Server sees admin's public key offered\n#    - PublicKeyHandler() is called, looks up admin user, stores in context\n#    - Server requests signature with admin's key\n#    - Attacker can't sign (doesn't have admin's private key), this key fails\n#    - Server tries next key (attacker's key)\n#    - PublicKeyHandler() called again with attacker's key\n#    - Server requests signature with attacker's key\n#    - Attacker signs successfully with their own private key\n# 4. Admin user is still in context from step 3, even though authentication\n#    succeeded with attacker's key!\n# 5. Attacker gains full Admin privileges\n#\n# THIS TEST VERIFIES:\n# - Using \"attacksoft\" command which offers both admin and attacker keys\n# - Attacker should NOT be able to perform admin user operations\n# - Attacker should NOT gain admin user privileges\n\n[windows] dos2unix notauthorizederr.txt\n\n# start soft serve\nexec soft serve &\n# wait for SSH server to start\nensureserverrunning SSH_PORT\n\n# Create a private repo as admin that only admin can access\nsoft repo create admin-only-repo -p\n\n# TEST 1: Simulate the attack using attacksoft command\n! attacksoft repo create attacker-created-repo\n\n# TEST 2: Verify attacker cannot access admin's private repo\n! attacksoft git-upload-pack admin-only-repo\ncmp stderr notauthorizederr.txt\n\n# TEST 3: Verify admin can still create repos (sanity check)\nsoft repo create admin-created-repo\n\n# TEST 4: Verify attacker cannot delete admin's repo\n! attacksoft repo delete admin-only-repo\n\n# TEST 5: Verify attacker cannot change settings\n! attacksoft settings anon-access read-write\n\n# stop the server\n[windows] stopserver\n# [windows] ! stderr . # FIXME: Windows returns error here, investigate and fix\n\n-- notauthorizederr.txt --\nError: you are not authorized to do this\n"
  },
  {
    "path": "testscript/testdata/config-servers-git_disabled.txtar",
    "content": "# vi: set ft=conf\n\n# disable git listening\nenv SOFT_SERVE_SSH_ENABLED=true\nenv SOFT_SERVE_GIT_ENABLED=false\nenv SOFT_SERVE_HTTP_ENABLED=true\nenv SOFT_SERVE_STATS_ENABLED=true\n\n# start soft serve\nexec soft serve --sync-hooks &\n\n# wait for the ssh + other servers to come up\nensureserverrunning SSH_PORT\nensureserverrunning HTTP_PORT\nensureserverrunning STATS_PORT\n\n# ensure that the disabled server is not running\nensureservernotrunning GIT_PORT\n"
  },
  {
    "path": "testscript/testdata/config-servers-http_disabled.txtar",
    "content": "# vi: set ft=conf\n\n# disable http listening\nenv SOFT_SERVE_SSH_ENABLED=true\nenv SOFT_SERVE_GIT_ENABLED=true\nenv SOFT_SERVE_HTTP_ENABLED=false\nenv SOFT_SERVE_STATS_ENABLED=true\n\n# start soft serve\nexec soft serve --sync-hooks &\n\n# wait for the ssh + other servers to come up\nensureserverrunning SSH_PORT\nensureserverrunning GIT_PORT\nensureserverrunning STATS_PORT\n\n# ensure that the disabled server is not running\nensureservernotrunning HTTP_PORT\n\n"
  },
  {
    "path": "testscript/testdata/config-servers-ssh_disabled.txtar",
    "content": "# vi: set ft=conf\n\n# disable ssh listening\nenv SOFT_SERVE_SSH_ENABLED=false\nenv SOFT_SERVE_GIT_ENABLED=true\nenv SOFT_SERVE_HTTP_ENABLED=true\nenv SOFT_SERVE_STATS_ENABLED=true\n\n# start soft serve\nexec soft serve --sync-hooks &\n\n# wait for the git + other servers to come up\nensureserverrunning GIT_PORT\nensureserverrunning HTTP_PORT\nensureserverrunning STATS_PORT\n\n# ensure that the disabled server is not running\nensureservernotrunning SSH_PORT\n"
  },
  {
    "path": "testscript/testdata/config-servers-stats_disabled.txtar",
    "content": "# vi: set ft=conf\n\n# disable stats listening\nenv SOFT_SERVE_SSH_ENABLED=true\nenv SOFT_SERVE_GIT_ENABLED=true\nenv SOFT_SERVE_HTTP_ENABLED=true\nenv SOFT_SERVE_STATS_ENABLED=false\n\n# start soft serve\nexec soft serve --sync-hooks &\n\n# wait for the ssh + other servers to come up\nensureserverrunning SSH_PORT\nensureserverrunning GIT_PORT\nensureserverrunning HTTP_PORT\n\n# ensure that the disabled server is not running\nensureservernotrunning STATS_PORT\n"
  },
  {
    "path": "testscript/testdata/help.txtar",
    "content": "# vi: set ft=conf\n[windows] dos2unix help.txt\n\n# start soft serve\nexec soft serve --sync-hooks &\n# wait for SSH server to start\nensureserverrunning SSH_PORT\n\nsoft --help\ncmpenv stdout help.txt\n\n# stop the server\n[windows] stopserver\n[windows] ! stderr .\n\n-- help.txt --\nSoft Serve is a self-hostable Git server for the command line.\n\nUsage:\n  ssh -p $SSH_PORT localhost [command]\n\nAvailable Commands:\n  help                 Help about any command\n  info                 Show your info\n  jwt                  Generate a JSON Web Token\n  pubkey               Manage your public keys\n  repo                 Manage repositories\n  set-username         Set your username\n  settings             Manage server settings\n  token                Manage access tokens\n  user                 Manage users\n\nFlags:\n  -h, --help   help for this command\n\nUse \"ssh -p $SSH_PORT localhost [command] --help\" for more information about a command.\n"
  },
  {
    "path": "testscript/testdata/http-cors.txtar",
    "content": "# vi: set ft=conf\n\n# FIXME: don't skip windows\n[windows] skip 'curl makes github actions hang'\n\n# convert crlf to lf on windows\n[windows] dos2unix http1.txt http2.txt http3.txt goget.txt gitclone.txt\n\n# start soft serve\nexec soft serve &\n# wait for SSH server to start\nensureserverrunning SSH_PORT\n\n# create user\nsoft user create user1 --key \"$USER1_AUTHORIZED_KEY\"\n\n# create access token\nsoft token create --expires-in '1h' 'repo2'\ncp stdout tokenfile\nenvfile TOKEN=tokenfile\nsoft token create --expires-in '1ns' 'repo2'\ncp stdout etokenfile\nenvfile ETOKEN=etokenfile\nusoft token create 'repo2'\ncp stdout utokenfile\nenvfile UTOKEN=utokenfile\n\n# push & create repo with some files, commits, tags...\nmkdir ./repo2\ngit -c init.defaultBranch=master -C repo2 init\nmkfile ./repo2/README.md '# Project\\nfoo'\nmkfile ./repo2/foo.png 'foo'\nmkfile ./repo2/bar.png 'bar'\ngit -C repo2 remote add origin http://$TOKEN@localhost:$HTTP_PORT/repo2\ngit -C repo2 lfs install --local\ngit -C repo2 lfs track '*.png'\ngit -C repo2 add -A\ngit -C repo2 commit -m 'first'\ngit -C repo2 tag v0.1.0\ngit -C repo2 push origin HEAD\ngit -C repo2 push origin HEAD --tags\n\n-- test 1 --\n# default public url is always allowed\ncurl -v --request OPTIONS http://localhost:$HTTP_PORT/repo2/git-upload-pack -H 'Origin: http://localhost:23232' -H 'Access-Control-Request-Method: POST'\nstderr '.*200 OK.*'\n\n# stop the server\nstopserver\n\n-- test 2 --\n# by default the server does not allow example.com, so the response does not have the \"Access-Control-Allow-Origin\" header and cors will fail.\n\n# restart soft serve\nexec soft serve &\n# wait for SSH server to start\nensureserverrunning SSH_PORT\n\ncurl -v --request OPTIONS http://localhost:$HTTP_PORT/repo2/git-upload-pack -H 'Origin: https://example.com' -H 'Access-Control-Request-Method: POST'\n! stderr '.*Access-Control-Allow-Origin.*'\n\n# stop the server\nstopserver\n\n-- test 3 --\n# allow cross-origin OPTIONS requests for example.com\nenv SOFT_SERVE_HTTP_CORS_ALLOWED_ORIGINS=\"https://example.com\"\nenv SOFT_SERVE_HTTP_CORS_ALLOWED_METHODS=\"GET,OPTIONS\"\nenv SOFT_SERVE_HTTP_CORS_ALLOWED_HEADERS=\"Origin,Access-Control-Request-Method\"\n\n# restart soft serve\nexec soft serve &\n# wait for SSH server to start\nensureserverrunning SSH_PORT\n\ncurl -v --request OPTIONS http://localhost:$HTTP_PORT/repo2.git/info/refs -H 'Origin: https://example.com' -H 'Access-Control-Request-Method: GET'\nstderr '.*200 OK.*'\n\n# stop the server\n[windows] stopserver\n[windows] ! stderr .\n"
  },
  {
    "path": "testscript/testdata/http.txtar",
    "content": "# vi: set ft=conf\n\n# FIXME: don't skip windows\n[windows] skip 'curl makes github actions hang'\n\n# convert crlf to lf on windows\n[windows] dos2unix http1.txt http2.txt http3.txt goget.txt gitclone.txt\n\n# start soft serve\nexec soft serve &\n# wait for SSH server to start\nensureserverrunning SSH_PORT\n\n# create user\nsoft user create user1 --key \"$USER1_AUTHORIZED_KEY\"\n\n# create access token\nsoft token create --expires-in '1h' 'repo2'\nstdout 'ss_*'\ncp stdout tokenfile\nenvfile TOKEN=tokenfile\nsoft token create --expires-in '1ns' 'repo2'\nstdout 'ss_*'\ncp stdout etokenfile\nenvfile ETOKEN=etokenfile\nusoft token create 'repo2'\nstdout 'ss_*'\ncp stdout utokenfile\nenvfile UTOKEN=utokenfile\n\n# push & create repo with some files, commits, tags...\nmkdir ./repo2\ngit -c init.defaultBranch=master -C repo2 init\nmkfile ./repo2/README.md '# Project\\nfoo'\nmkfile ./repo2/foo.png 'foo'\nmkfile ./repo2/bar.png 'bar'\ngit -C repo2 remote add origin http://$TOKEN@localhost:$HTTP_PORT/repo2\ngit -C repo2 lfs install --local\ngit -C repo2 lfs track '*.png'\ngit -C repo2 add -A\ngit -C repo2 commit -m 'first'\ngit -C repo2 tag v0.1.0\ngit -C repo2 push origin HEAD\ngit -C repo2 push origin HEAD --tags\n\n# dumb http git\ncurl -XGET http://localhost:$HTTP_PORT/repo2.git/info/refs\nstdout '[0-9a-z]{40}\trefs/heads/master\\n[0-9a-z]{40}\trefs/tags/v0.1.0'\n\n# http errors\ncurl -XGET http://localhost:$HTTP_PORT/repo2111foobar.git/foo/bar\nstdout '404.*'\ncurl -XGET http://localhost:$HTTP_PORT/repo2111/foobar.git/foo/bar\nstdout '404.*'\ncurl -XGET http://localhost:$HTTP_PORT/repo2.git/foo/bar\nstdout '404.*'\ncurl -XPOST http://$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/foo\nstdout '404.*'\ncurl -XGET http://localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch\nstdout '.*Method Not Allowed.*'\ncurl -XPOST http://$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch\nstdout '.*Not Acceptable.*'\ncurl -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' http://$TOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch\nstdout '.*validation error.*'\ncurl -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' -d '{}' http://$TOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch\nstdout '.*no objects found.*'\ncurl -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' -d '{\"operation\":\"download\",\"transfers\":[\"foo\"]}' http://$TOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch\nstdout '.*unsupported transfer.*'\ncurl -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' -d '{\"operation\":\"bar\",\"objects\":[{}]}' http://$TOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch\nstdout '.*unsupported operation.*'\ncurl -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' -d '{\"operation\":\"download\",\"objects\":[{}]}' http://$TOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch\ncmp stdout http1.txt\ncurl -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' -d '{\"operation\":\"upload\",\"objects\":[{}]}' http://$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch\nstdout '.*write access required.*'\ncurl -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' -d '{\"operation\":\"upload\",\"objects\":[{}]}' http://$TOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch\ncmp stdout http1.txt\n\n\n# go-get allow (public repo)\ncurl http://localhost:$HTTP_PORT/repo2.git?go-get=1\ncmpenv stdout goget.txt\ncurl http://localhost:$HTTP_PORT/repo2.git/subpackage?go-get=1\ncmpenv stdout goget.txt\ncurl http://localhost:$HTTP_PORT/repo2/subpackage?go-get=1\ncmpenv stdout goget.txt\n\n# go-get not found (invalid method)\ncurl -XPOST http://localhost:$HTTP_PORT/repo2/subpackage?go-get=1\nstdout '404.*'\n\n# go-get not found (invalid repo)\ncurl -XPOST http://localhost:$HTTP_PORT/repo299/subpackage?go-get=1\nstdout '404.*'\n\n# set private\nsoft repo private repo2 true\n\n# allow access private\ncurl -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' http://$TOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch\ncmp stdout http2.txt\ncurl -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' http://$ETOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch\ncmp stdout http3.txt\n\n# deny access private\ncurl http://localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch\nstdout '.*credentials needed.*'\ncurl http://$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch\nstdout '.*credentials needed.*'\ncurl http://0$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch\ncmp stdout http3.txt\n\n# deny dumb http git\ncurl -XGET http://localhost:$HTTP_PORT/repo2.git/info/refs\nstdout '404.*'\n\n# deny access ask for credentials\n# this means the server responded with a 401 and prompted for credentials\n# but we disable git terminal prompting to we get a fatal instead of a 401 \"Unauthorized\"\n! git clone http://localhost:$HTTP_PORT/repo2 repo2_clone\ncmpenv stderr gitclone.txt\n! git clone http://someuser:somepassword@localhost:$HTTP_PORT/repo2 repo2_clone\nstderr '.*403.*'\n\n# go-get not found (private repo)\ncurl http://localhost:$HTTP_PORT/repo2.git?go-get=1\nstdout '404.*'\n\n# go-get forbidden (private repo & expired token)\ncurl http://$ETOKEN@localhost:$HTTP_PORT/repo2.git?go-get=1\nstdout '403.*'\n\n# go-get not found (private repo & different user)\ncurl http://$UTOKEN@localhost:$HTTP_PORT/repo2.git?go-get=1\nstdout '404.*'\n\n# go-get with creds\ncurl http://$TOKEN@localhost:$HTTP_PORT/repo2.git?go-get=1\ncmpenv stdout goget.txt\n\n# stop the server\n[windows] stopserver\n[windows] ! stderr .\n\n-- http1.txt --\n{\"transfer\":\"basic\",\"objects\":[{\"oid\":\"\",\"size\":0,\"error\":{\"code\":422,\"message\":\"invalid object\"}}],\"hash_algo\":\"sha256\"}\n-- http2.txt --\n{\"message\":\"validation error in request: EOF\"}\n-- http3.txt --\n{\"message\":\"bad credentials\"}\n-- goget.txt --\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\"/>\n    <meta http-equiv=\"refresh\" content=\"0; url=https://godoc.org/localhost:$HTTP_PORT/repo2\">\n    <meta name=\"go-import\" content=\"localhost:$HTTP_PORT/repo2 git http://localhost:$HTTP_PORT/repo2.git\">\n</head>\n<body>\nRedirecting to docs at <a href=\"https://godoc.org/localhost:$HTTP_PORT/repo2\">godoc.org/localhost:$HTTP_PORT/repo2</a>...\n</body>\n</html>\n-- gitclone.txt --\nCloning into 'repo2_clone'...\nfatal: could not read Username for 'http://localhost:$HTTP_PORT': terminal prompts disabled\n"
  },
  {
    "path": "testscript/testdata/jwt.txtar",
    "content": "# vi: set ft=conf\n\n# start soft serve\nexec soft serve &\n# wait for SSH server to start\nensureserverrunning SSH_PORT\n\n# create user\nsoft user create user1 --key \"$USER1_AUTHORIZED_KEY\"\n\n# generate jwt token\nsoft jwt\nstdout '.*\\..*\\..*'\nsoft jwt repo\nstdout '.*\\..*\\..*'\nusoft jwt\nstdout '.*\\..*\\..*'\nusoft jwt repo\nstdout '.*\\..*\\..*'\n\n# stop the server\n[windows] stopserver\n[windows] ! stderr .\n"
  },
  {
    "path": "testscript/testdata/mirror.txtar",
    "content": "# vi: set ft=conf\n\n# convert crlf to lf on windows\n[windows] dos2unix info1.txt info2.txt tree.txt\n\n# start soft serve\nexec soft serve &\n# wait for SSH server to start\nensureserverrunning SSH_PORT\n\n# import a repo\nsoft repo import --mirror charmbracelet/wizard-tutorial https://github.com/charmbracelet/wizard-tutorial.git\n\n# check empty description file\nreadfile $DATA_PATH/repos/charmbracelet/wizard-tutorial.git/description ''\n\n# check repo info\nsoft repo info charmbracelet/wizard-tutorial\ncmp stdout info1.txt\n\n# check repo list\nsoft repo list\nstdout charmbracelet/wizard-tutorial\n\n# is-mirror?\nsoft repo is-mirror charmbracelet/wizard-tutorial\nstdout true\n\n# set project name\nsoft repo project-name charmbracelet/wizard-tutorial wizard-tutorial\nsoft repo list\nstdout wizard-tutorial\n\n\n# check description\nsoft repo description charmbracelet/wizard-tutorial\n! stdout .\n\n# set description\nsoft repo description charmbracelet/wizard-tutorial \"testing repo\"\nsoft repo description charmbracelet/wizard-tutorial\nstdout 'testing repo'\nreadfile $DATA_PATH/repos/charmbracelet/wizard-tutorial.git/description 'testing repo'\n\n# rename\nsoft repo rename charmbracelet/wizard-tutorial charmbracelet/test\nsoft repo list\nstdout charmbracelet/test # TODO: shouldn't this still show the project-name?\n\n# check its not private\nsoft repo private charmbracelet/test\nstdout false\nexists $DATA_PATH/repos/charmbracelet/test.git/git-daemon-export-ok\n\n# make it private\nsoft repo private charmbracelet/test  true\nsoft repo private charmbracelet/test\nstdout true\n! exists $DATA_PATH/repos/charmbracelet/test.git/git-daemon-export-ok\n\n# check its not hidden\nsoft repo hidden charmbracelet/test\nstdout false\n\n# make it hidden\nsoft repo hidden charmbracelet/test  true\nsoft repo hidden charmbracelet/test\nstdout true\n\n# print tree\nsoft repo tree charmbracelet/test\ncmp stdout tree.txt\n\n# check repo info again\nsoft repo info charmbracelet/test\ncmp stdout info2.txt\n\n# get a file\nsoft repo blob charmbracelet/test README.md\nstdout '.*Wizard.*'\n\n# stop the server\n[windows] stopserver\n[windows] ! stderr .\n\n\n-- info1.txt --\nProject Name:\nRepository: charmbracelet/wizard-tutorial\nDescription:\nPrivate: false\nHidden: false\nMirror: true\nOwner: admin\nDefault Branch: main\nBranches:\n  - main\n-- info2.txt --\nProject Name: wizard-tutorial\nRepository: charmbracelet/test\nDescription: testing repo\nPrivate: true\nHidden: true\nMirror: true\nOwner: admin\nDefault Branch: main\nBranches:\n  - main\n-- tree.txt --\n-rw-r--r--\t10 B\t .gitignore\n-rw-r--r--\t1.3 kB\t README.md\n-rw-r--r--\t970 B\t go.mod\n-rw-r--r--\t5.3 kB\t go.sum\n-rw-r--r--\t2.2 kB\t input.go\n-rw-r--r--\t2.9 kB\t main.go\n"
  },
  {
    "path": "testscript/testdata/repo-blob.txtar",
    "content": "# vi: set ft=conf\n\n# convert crlf to lf on windows\n[windows] dos2unix blob1.txt blob2.txt blob3.txt\n\n# start soft serve\nexec soft serve &\n# wait for SSH server to start\nensureserverrunning SSH_PORT\n\n# create a repo\nsoft repo create repo1\n\n# clone repo\ngit clone ssh://localhost:$SSH_PORT/repo1 repo1\n\n# create some files, commits, tags...\nmkfile ./repo1/README.md '# Hello\\n\\nwelcome'\nmkfile ./repo1/main.go 'package main\\nconst foo = 2\\n'\nmkfile ./repo1/.hidden ''\nmkdir ./repo1/folder\nmkdir ./repo1/.folder\nmkfile ./repo1/folder/lib.c '//#include <stdio.h>'\ngit -C repo1 add -A\ngit -C repo1 commit -m 'first'\ngit -C repo1 push origin HEAD\n\n# print root blob\nsoft repo blob repo1 README.md\ncmp stdout blob1.txt\n\n# print file blob with revision with line numbers and colors\nsoft repo blob repo1 master main.go -l -c\ncmp stdout blob2.txt\n\n\n# print file blob with revision within folder with lineno\nsoft repo blob repo1 master folder/lib.c -l\ncmp stdout blob3.txt\n\n# print blob of folder that does not exist\n! soft repo blob repo1 folder/nope.txt\n! stdout .\nstderr 'revision does not exist'\n\n# print blob of bad revision\n! soft repo blob repo1 badrev README.md\n! stdout .\nstderr 'revision does not exist'\n\n# stop the server\n[windows] stopserver\n\n-- blob1.txt --\n# Hello\\n\\nwelcome\n-- blob2.txt --\n 1 │ package main\\nconst foo = 2\\n\n-- blob3.txt --\n 1 │ //#include <stdio.h>\n"
  },
  {
    "path": "testscript/testdata/repo-collab.txtar",
    "content": "# vi: set ft=conf\n\n# start soft serve\nexec soft serve &\n# wait for SSH server to start\nensureserverrunning SSH_PORT\n\n# setup\nsoft repo import test https://github.com/charmbracelet/wizard-tutorial.git\nsoft user create foo --key \"$USER1_AUTHORIZED_KEY\"\n\n# list collabs\nsoft repo collab list test\n! stdout .\n\n# add collab\nsoft repo collab add test foo\nsoft repo collab list test\nstdout 'foo'\n\n# remove collab\nsoft repo collab remove test foo\nsoft repo collab list test\n! stdout .\n\n# create empty repo\nsoft repo create empty '-d \"empty repo\"'\n\n# add collab\nsoft repo collab add empty foo\n# add collab again\n# test issue #464 https://github.com/charmbracelet/soft-serve/issues/464\n! soft repo collab add empty foo\nstderr '.*already exists.*'\n# a placeholder to reset stderr\nsoft help\n\n# stop the server\n[windows] stopserver\n[windows] ! stderr .\n"
  },
  {
    "path": "testscript/testdata/repo-commit.txtar",
    "content": "# vi: set ft=conf\n\n# convert crlf to lf on windows\n[windows] dos2unix commit1.txt\n\n# start soft serve\nexec soft serve &\n# wait for SSH server to start\nensureserverrunning SSH_PORT\n\n# create a repo\nsoft repo import basic1 https://github.com/git-fixtures/basic\n\n# print commit\nsoft repo commit basic1 b8e471f58bcbca63b07bda20e428190409c2db47\ncmp stdout commit1.txt\n\n# stop the server\n[windows] stopserver\n[windows] ! stderr .\n\n-- commit1.txt --\ncommit b8e471f58bcbca63b07bda20e428190409c2db47\nAuthor: Daniel Ripolles\nDate:   Tue Mar 31 11:44:52 UTC 2015\nCreating changelog\n\n\nCHANGELOG | 1 +\n1 file changed, 1 insertion(+)\n\ndiff --git a/CHANGELOG b/CHANGELOG\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..d3ff53e0564a9f87d8e84b6e28e5060e517008aa\n--- /dev/null\n+++ b/CHANGELOG\n@@ -0,0 +1 @@\n+Initial changelog\n\n"
  },
  {
    "path": "testscript/testdata/repo-create.txtar",
    "content": "# vi: set ft=conf\n\n# convert crlf to lf on windows\n[windows] dos2unix readme.md branch_list.1.txt info.txt\n\n# start soft serve\nexec soft serve &\n# wait for SSH server to start\nensureserverrunning SSH_PORT\n\n# create a repo\nsoft repo create repo1 -d 'description' -H -p -n 'repo11'\nstderr 'Created repository repo1.*'\nstdout ssh://localhost:$SSH_PORT/repo1.git\nsoft repo hidden repo1\nstdout true\nsoft repo private repo1\nstdout true\n! exists $DATA_PATH/repos/repo1.git/git-daemon-export-ok\nsoft repo description repo1\nstdout 'description'\nreadfile $DATA_PATH/repos/repo1.git/description 'description'\nsoft repo project-name repo1\nstdout 'repo1'\n\n# clone repo\ngit clone ssh://localhost:$SSH_PORT/repo1 repo1\n\n# create some files, commits, tags...\nmkfile ./repo1/README.md '# Project\\nfoo'\ngit -C repo1 add -A\ngit -C repo1 commit -m 'first'\ngit -C repo1 tag v0.1.0\ngit -C repo1 push origin HEAD\ngit -C repo1 push origin HEAD --tags\n\n# create lfs files, use ssh git-lfs-transfer\ngit -C repo1 lfs install --local\ngit -C repo1 lfs track '*.png'\ngit -C repo1 lfs track '*.mp4'\nmkfile ./repo1/foo.png 'foo'\nmkfile ./repo1/bar.png 'bar'\ngit -C repo1 add -A\ngit -C repo1 commit -m 'lfs'\ngit -C repo1 push origin HEAD\n\n# info\nsoft repo info repo1\ncmp stdout info.txt\n\n# list tags\nsoft repo tag list repo1\nstdout 'v0.1.0'\n\n# delete tag\nsoft repo tag delete repo1 v0.1.0\nsoft repo tag list repo1\n! stdout .\n\n# print tree\nsoft repo tree repo1\ncp stdout tree.txt\ngrep '.gitattributes' tree.txt\ngrep 'README.md' tree.txt\ngrep 'foo.png' tree.txt\ngrep 'bar.png' tree.txt\n\n# cat blob\nsoft repo blob repo1 README.md\ncmp stdout readme.md\n\n# cat blob that doesn't exist\n! soft repo blob repo1 README.txt\n! stdout .\nstderr '.*revision does not exist.*'\n\n# check main branch\nsoft repo branch default repo1\nstdout master\n\n# create a new branch\ngit -C repo1 checkout -b branch1\ngit -C repo1 push origin branch1\nsoft repo branch list repo1\ncmp stdout branch_list.1.txt\n\n# change default branch\nsoft repo branch default repo1 branch1\nsoft repo branch default repo1\nstdout branch1\n\n# cannot delete main branch\n! soft repo branch delete repo1 branch1\n\n# delete other branch\nsoft repo branch delete repo1 master\nsoft repo branch list repo1\nstdout branch1\n\n# create a new user\nsoft user create bar --key \"$USER1_AUTHORIZED_KEY\"\n\n# user create a repo\nusoft repo create repo2 -d 'description' -H -p -n 'repo2'\nstderr 'Created repository repo2.*'\nstdout ssh://localhost:$SSH_PORT/repo2.git\nusoft repo hidden repo2\nstdout true\nusoft repo private repo2\nstdout true\n! exists $DATA_PATH/repos/repo2.git/git-daemon-export-ok\nusoft repo description repo2\nstdout 'description'\nreadfile $DATA_PATH/repos/repo2.git/description 'description'\nusoft repo project-name repo2\nstdout 'repo2'\n\n# user delete a repo\nusoft repo delete repo2\n! exists $DATA_PATH/repos/repo2.git\n\n# stop the server\n[windows] stopserver\n[windows] ! stderr .\n\n\n-- readme.md --\n# Project\\nfoo\n-- branch_list.1.txt --\nbranch1\nmaster\n-- info.txt --\nProject Name: repo11\nRepository: repo1\nDescription: description\nPrivate: true\nHidden: true\nMirror: false\nOwner: admin\nDefault Branch: master\nBranches:\n  - master\nTags:\n  - v0.1.0\n"
  },
  {
    "path": "testscript/testdata/repo-delete.txtar",
    "content": "# vi: set ft=conf\n\n# start soft serve\nexec soft serve &\n# wait for SSH server to start\nensureserverrunning SSH_PORT\n\nsoft repo create repo1\nsoft repo create repo-to-delete\nsoft repo delete repo-to-delete\n! soft repo delete nope\nstderr '.*not found.*'\n\n# missing argument should fail\n! soft repo branch delete repo1\nstderr 'Error.*accepts 2 arg.*'\n\nsoft repo list\nstdout 'repo1'\n\n# stop the server\n[windows] stopserver\n[windows] ! stderr .\n"
  },
  {
    "path": "testscript/testdata/repo-import-local-path.txtar",
    "content": "# vi: set ft=conf\n\n[windows] skip 'uses a raw server filesystem path as the import remote'\n\n# start soft serve\nexec soft serve &\n# wait for SSH server to start\nensureserverrunning SSH_PORT\n\n# create a private repo and a second user\nsoft repo create secret -p\nsoft user create user1 --key \"$USER1_AUTHORIZED_KEY\"\n\n# seed the private repo with content\ngit clone ssh://localhost:$SSH_PORT/secret secret\nmkfile ./secret/SECRET.txt 'top secret'\ngit -C secret add -A\ngit -C secret commit -m 'first'\ngit -C secret push origin HEAD\n\n# user1 cannot read the private repo directly\n! usoft repo info secret\nstderr 'repository not found'\n\n# user1 also must not be able to import the server-local repo path\n! usoft repo import stolen \"$DATA_PATH/repos/secret.git\" --lfs-endpoint http://example.com\nstderr 'remote must be a network URL'\n\n# the failed import must not create a readable repo\n! usoft repo info stolen\nstderr 'repository not found'\n\n[windows] stopserver\n[windows] ! stderr .\n"
  },
  {
    "path": "testscript/testdata/repo-import.txtar",
    "content": "# vi: set ft=conf\n\n# convert crlf to lf on windows\n[windows] dos2unix repo3.txt\n\n# start soft serve\nexec soft serve &\n# wait for SSH server to start\nensureserverrunning SSH_PORT\n\n# import private\nsoft repo import --private repo1 https://github.com/charmbracelet/wizard-tutorial.git\nsoft repo private repo1\nstdout 'true'\n\n# import hidden\nsoft repo import --hidden repo2 https://github.com/charmbracelet/wizard-tutorial.git\nsoft repo hidden repo2\nstdout 'true'\n\n# import with name and description\nsoft repo import --name 'repo33' --description 'descriptive' repo3 https://github.com/charmbracelet/wizard-tutorial.git\nsoft repo info repo3\ncmp stdout repo3.txt\n\n# stop the server\n[windows] stopserver\n[windows] ! stderr .\n\n-- repo3.txt --\nProject Name: repo33\nRepository: repo3\nDescription: descriptive\nPrivate: false\nHidden: false\nMirror: false\nOwner: admin\nDefault Branch: main\nBranches:\n  - main\n"
  },
  {
    "path": "testscript/testdata/repo-perms.txtar",
    "content": "# vi: set ft=conf\n\n# convert crlf to lf on windows\n[windows] dos2unix info.txt\n\n# start soft serve\nexec soft serve &\n# wait for SSH server to start\nensureserverrunning SSH_PORT\n\n# create a repo & user1 with admin\nsoft repo create repo1 -p\nsoft user create user1 -k \"$USER1_AUTHORIZED_KEY\"\n\n# setup repo\ngit clone ssh://localhost:$SSH_PORT/repo1 repo1\nmkfile ./repo1/README.md '# Project\\nfoo'\ngit -C repo1 add -A\ngit -C repo1 commit -m 'first'\ngit -C repo1 tag v1.0.0\ngit -C repo1 push origin HEAD\ngit -C repo1 push origin HEAD --tags\n\n# admin can access it\nsoft repo tree repo1\nsoft repo blob repo1 README.md\nsoft repo description repo1 'desc'\nsoft repo project-name repo1 'proj'\nsoft repo private repo1\nsoft repo info repo1\ncmp stdout info.txt\n\n# verify no collab\nsoft repo collab list repo1\n! stdout .\n\n# regular user can't access it\n! usoft repo info repo1\nstderr 'repository not found'\n! usoft repo tree repo1\nstderr 'repository not found'\n! usoft repo tag list repo1\nstderr 'repository not found'\n! usoft repo tag delete repo1 v1.0.0\nstderr 'repository not found'\n! usoft repo blob repo1 README.md\nstderr 'repository not found'\n! usoft repo description repo1\nstderr 'repository not found'\n! usoft repo description repo1 'new desc'\nstderr 'repository not found'\n! usoft repo project-name repo1\nstderr 'repository not found'\n! usoft repo private repo1 true\nstderr 'repository not found'\n! usoft repo private repo1\nstderr 'repository not found'\n! usoft repo rename repo1 repo11\nstderr 'repository not found'\n! usoft repo branch default repo1\nstderr 'repository not found'\n! usoft repo branch default repo1 main\nstderr 'repository not found'\n! usoft repo delete repo1\nstderr 'repository not found'\n\n# add user1 as collab\n! soft repo collab add repo1 user1 foobar\nstderr 'invalid access level'\nsoft repo collab add repo1 user1 read-write\nsoft repo collab list repo1\nstdout user1\nusoft repo collab list repo1\nstdout user1\n\n# verify user1 has access now\nusoft repo info repo1\ncmp stdout info.txt\n\n# delete\nusoft repo delete repo1\nusoft repo list\n! stdout .\n\n# stop the server\n[windows] stopserver\n[windows] ! stderr .\n\n-- info.txt --\nProject Name: proj\nRepository: repo1\nDescription: desc\nPrivate: true\nHidden: false\nMirror: false\nOwner: admin\nDefault Branch: master\nBranches:\n  - master\nTags:\n  - v1.0.0\n"
  },
  {
    "path": "testscript/testdata/repo-push.txtar",
    "content": "# vi: set ft=conf\n\n# start soft serve\nexec soft serve &\n# wait for SSH server to start\nensureserverrunning SSH_PORT\n\n# create a repo\nsoft repo create repo-empty -d 'description' -H -p -n 'repo-empty'\n\n# clone repo\ngit clone ssh://localhost:$SSH_PORT/repo-empty repo-empty\n\n# push repo without any commits\n! git -C repo-empty push origin HEAD\n\n# push repo with a commit\nmkfile ./repo-empty/README.md '# Hello\\n\\nwelcome'\ngit -C repo-empty add README.md\ngit -C repo-empty commit -m 'first'\ngit -C repo-empty push origin HEAD\n\n# stop the server\n[windows] stopserver\n"
  },
  {
    "path": "testscript/testdata/repo-tree.txtar",
    "content": "# vi: set ft=conf\n\n# convert crlf to lf on windows\n[windows] dos2unix tree1.txt tree2.txt tree3.txt tree4.txt\n\n# start soft serve\nexec soft serve &\n# wait for SSH server to start\nensureserverrunning SSH_PORT\n\n# create a repo\nsoft repo create repo1\n\n# clone repo\ngit clone ssh://localhost:$SSH_PORT/repo1 repo1\n\n# create some files, commits, tags...\nmkfile ./repo1/README.md '# Hello'\nmkfile ./repo1/b.md 'hi'\nmkfile ./repo1/.hidden ''\nmkdir ./repo1/folder\nmkdir ./repo1/.folder\nmkfile ./repo1/folder/aa.md 'hello'\ngit -C repo1 add -A\ngit -C repo1 commit -m 'first'\ngit -C repo1 push origin HEAD\n\n# print root tree\nsoft repo tree repo1\ncmp stdout tree1.txt\n\n# print folder tree\nsoft repo tree repo1 folder\ncmp stdout tree2.txt\n\n# print file tree with revision\nsoft repo tree repo1 master b.md\ncmp stdout tree3.txt\n\n# print tree of folder that does not exist\n! soft repo tree repo1 folder2\n! stdout .\nstderr 'file not found'\n\n# print tree of bad revision\n! soft repo tree repo1 badrev folder\n! stdout .\nstderr 'revision does not exist'\n\n# test unicode file name issue #457\nsoft repo create repo4\ngit clone ssh://localhost:$SSH_PORT/repo4 repo4\nmkfile ./repo4/🍕.md '🍕'\ngit -C repo4 add -A\ngit -C repo4 commit -m 'unicode'\ngit -C repo4 push origin HEAD\n\n# print root tree\nsoft repo tree repo4\ncmp stdout tree4.txt\n\n# stop the server\n[windows] stopserver\n\n-- tree1.txt --\ndrwxrwxrwx\t-\t folder\n-rw-r--r--\t-\t .hidden\n-rw-r--r--\t7 B\t README.md\n-rw-r--r--\t2 B\t b.md\n-- tree2.txt --\n-rw-r--r--\t5 B\t aa.md\n-- tree3.txt --\n-rw-r--r--\t2 B\t b.md\n-- tree4.txt --\n-rw-r--r--\t4 B\t 🍕.md\n"
  },
  {
    "path": "testscript/testdata/repo-webhook-ssrf.txtar",
    "content": "# vi: set ft=conf\n\n# Test SSRF protection in webhook creation\n\n# start soft serve\nexec soft serve &\n# wait for SSH server to start\nensureserverrunning SSH_PORT\n\n# create a repo\nsoft repo create test-repo\nstderr 'Created repository test-repo.*'\n\n# Try to create webhook with localhost - should fail\n! soft repo webhook create test-repo http://localhost:8080/webhook -e push\n\n# Try to create webhook with 127.0.0.1 - should fail\n! soft repo webhook create test-repo http://127.0.0.1:8080/webhook -e push\n\n# Try to create webhook with AWS metadata service - should fail\n! soft repo webhook create test-repo http://169.254.169.254/latest/meta-data/ -e push\n\n# Try to create webhook with private network - should fail\n! soft repo webhook create test-repo http://192.168.1.1/webhook -e push\n\n# Try to create webhook with private 10.x network - should fail\n! soft repo webhook create test-repo http://10.0.0.1/webhook -e push\n\n# Create webhook with valid public IP - should succeed\nnew-webhook WH_PUBLIC\nsoft repo webhook create test-repo $WH_PUBLIC -e push\n\n# List webhooks - should show only the valid one\nsoft repo webhook list test-repo\nstdout 'webhook.site'\n\n# Try to update webhook to localhost - should fail\n! soft repo webhook update test-repo 1 --url http://localhost:9090/hook\n\n# stop the server\n[windows] stopserver\n"
  },
  {
    "path": "testscript/testdata/repo-webhooks.txtar",
    "content": "# vi: set ft=conf\n\n# start soft serve\nexec soft serve &\n# wait for SSH server to start\nensureserverrunning SSH_PORT\n\n# create a repo\nsoft repo create repo-123\nstderr 'Created repository repo-123.*'\nstdout ssh://localhost:$SSH_PORT/repo-123.git\n\n# create webhook\nnew-webhook WH_REPO_123\nsoft repo webhook create repo-123 $WH_REPO_123 -e branch_tag_create -e branch_tag_delete -e collaborator -e push -e repository -e repository_visibility_change\n\n# list webhooks\nsoft repo webhook list repo-123\nstdout '1.*webhook.site/.*'\n\n# clone repo and commit files\ngit clone ssh://localhost:$SSH_PORT/repo-123 repo-123\nmkfile ./repo-123/README.md 'foobar'\ngit -C repo-123 add -A\ngit -C repo-123 commit -m 'first'\ngit -C repo-123 push origin HEAD\n\n# list webhook deliveries\nsoft repo webhook deliver list repo-123 1\nstdout '✅.*push.*'\n\n# stop the server\n[windows] stopserver\n[windows] ! stderr .\n"
  },
  {
    "path": "testscript/testdata/set-username.txtar",
    "content": "# vi: set ft=conf\n\n# convert crlf to lf on windows\n[windows] dos2unix info1.txt info2.txt\n\n# start soft serve\nexec soft serve &\n# wait for SSH server to start\nensureserverrunning SSH_PORT\n\n# get original username\nsoft info\ncmpenv stdout info1.txt\n\n# set another username\nsoft set-username test\nsoft info\ncmpenv stdout info2.txt\n\n# stop the server\n[windows] stopserver\n[windows] ! stderr .\n\n-- info1.txt --\nUsername: admin\nAdmin: true\nPublic keys:\n  $ADMIN1_AUTHORIZED_KEY\n-- info2.txt --\nUsername: test\nAdmin: true\nPublic keys:\n  $ADMIN1_AUTHORIZED_KEY\n"
  },
  {
    "path": "testscript/testdata/settings.txtar",
    "content": "# vi: set ft=conf\n\n# start soft serve\nexec soft serve &\n# wait for SSH server to start\nensureserverrunning SSH_PORT\n\n# check default allow-keyless\nsoft settings allow-keyless true\nsoft settings allow-keyless\nstdout 'true.*'\n\n# change allow-keyless and check\nsoft settings allow-keyless false\nsoft settings allow-keyless\nstdout 'false.*'\n\n# check default anon-access\nsoft settings anon-access\nstdout 'read-only.*'\n\n# change anon-access to all available options, and check them\nsoft settings anon-access no-access\nsoft settings anon-access\nstdout 'no-access.*'\n\nsoft settings anon-access read-only\nsoft settings anon-access\nstdout 'read-only.*'\n\nsoft settings anon-access read-write\nsoft settings anon-access\nstdout 'read-write.*'\n\nsoft settings anon-access admin-access\nsoft settings anon-access\nstdout 'admin-access.*'\n\n# try to set a bad access\n! soft settings anon-access nope\n! stdout .\nstderr .\n\n# stop the server\n[windows] stopserver\n\n"
  },
  {
    "path": "testscript/testdata/soft-browse.txtar",
    "content": "# vi: set ft=conf\n\n[windows] skip\n\n# clone repo\n#git clone https://github.com/charmbracelet/wizard-tutorial.git wizard-tutorial\n\n# run soft browse\n# disable this temporarily\n#ttyin input.txt\n#exec soft browse ./wizard-tutorial\n\n# cd and run soft\n# disable this temporarily\n#cd wizard-tutorial\n#ttyin ../input.txt\n#exec soft\n\n-- input.txt --\njjkkdduu\n\njjkkdduu\n\njjkkdduu\n\njjkkdduu\n\njjkkdduu\n\nqqq\n"
  },
  {
    "path": "testscript/testdata/soft-manpages.txtar",
    "content": "# vi: set ft=conf\n\n# test `soft man` output\nexec soft man\nstdout .\n"
  },
  {
    "path": "testscript/testdata/ssh-lfs.txtar",
    "content": "# vi: set ft=conf\n\n[windows] dos2unix err1.txt err2.txt err3.txt errauth.txt\n\nskip 'breaks with git-lfs 3.5.1'\n\n# enable ssh lfs transfer\nenv SOFT_SERVE_LFS_SSH_ENABLED=true\n# start soft serve\nexec soft serve &\n# wait for SSH server to start\nensureserverrunning SSH_PORT\n\n# create a user\nsoft user create foo --key \"$USER1_AUTHORIZED_KEY\"\n\n# create a repo\nsoft repo create repo1\nsoft repo create repo1p -p\nusoft repo create repo2\nusoft repo create repo2p -p\n\n# SSH Git LFS Transfer command\n! soft git-lfs-transfer\ncmp stderr err1.txt\n! soft git-lfs-transfer repo1\ncmp stderr err2.txt\nsoft git-lfs-transfer repo1 download\nstdout '000eversion=1\\n000clocking\\n0000'\nsoft git-lfs-transfer repo1 upload\nstdout '000eversion=1\\n000clocking\\n0000'\nusoft git-lfs-transfer repo1 download\nstdout '000eversion=1\\n000clocking\\n0000'\n! usoft git-lfs-transfer repo1 upload\ncmp stderr errauth.txt\n\n# Unauthorized user\n! usoft git-lfs-transfer\ncmp stderr err1.txt\n! usoft git-lfs-transfer repo1p\ncmp stderr err2.txt\n! usoft git-lfs-transfer repo1p download\ncmp stderr errauth.txt\n! usoft git-lfs-transfer repo1p upload\ncmp stderr errauth.txt\n\n# push & create repo with some files, commits, tags...\nmkdir ./repo1\ngit -c init.defaultBranch=master -C repo1 init\nmkfile ./repo1/README.md '# Project\\nfoo'\nmkfile ./repo1/foo.png 'foo'\nmkfile ./repo1/bar.png 'bar'\ngit -C repo1 remote add origin ssh://localhost:$SSH_PORT/repo1\ngit -C repo1 lfs install --local\ngit -C repo1 lfs track '*.png'\ngit -C repo1 add -A\ngit -C repo1 commit -m 'first'\ngit -C repo1 tag v0.1.0\ngit -C repo1 push origin HEAD\ngit -C repo1 push origin HEAD --tags\n\n# clone repo with ssh lfs-transfer\ngit clone ssh://localhost:$SSH_PORT/repo1 repo1c\nexists repo1c/README.md\nexists repo1c/foo.png\nexists repo1c/bar.png\n\n# stop the server\n[windows] stopserver\n\n-- err1.txt --\nError: accepts 2 arg(s), received 0\n-- err2.txt --\nError: accepts 2 arg(s), received 1\n-- err3.txt --\nError: invalid request\n-- errauth.txt --\nError: you are not authorized to do this\n"
  },
  {
    "path": "testscript/testdata/ssh.txtar",
    "content": "# vi: set ft=conf\n\n[windows] dos2unix argserr1.txt argserr2.txt argserr3.txt invalidrepoerr.txt notauthorizederr.txt\n\n# start soft serve\nexec soft serve &\n# wait for SSH server to start\nensureserverrunning SSH_PORT\n\n# create a user\nsoft user create foo --key \"$USER1_AUTHORIZED_KEY\"\n\n# create a repo\nsoft repo create repo1\nsoft repo create repo1p -p\nusoft repo create repo2\nusoft repo create repo2p -p\n\n# SSH Git commands as admin\n! soft git-upload-pack\ncmp stderr argserr1.txt\n! soft git-upload-pack foobar\ncmp stderr invalidrepoerr.txt\n! soft git-upload-archive\ncmp stderr argserr1.txt\n! soft git-upload-archive foobar\ncmp stderr invalidrepoerr.txt\n! soft git-receive-pack\ncmp stderr argserr1.txt\n! soft git-receive-pack foobar\nstdout '.*0000 capabilities.*git.*' # git pack response\nstderr '.*something went wrong.*'\n! soft git-lfs-authenticate\ncmp stderr argserr2.txt\n! soft git-lfs-authenticate foobar\ncmp stderr argserr3.txt\n! soft git-lfs-authenticate foobar download\ncmp stderr invalidrepoerr.txt\n! soft git-lfs-authenticate foobar upload\ncmp stderr invalidrepoerr.txt\nsoft git-lfs-authenticate repo1 download\nstdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'\nsoft git-lfs-authenticate repo1 upload\nstdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'\nsoft git-lfs-authenticate repo1p download\nstdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'\nsoft git-lfs-authenticate repo1p upload\nstdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'\nsoft git-lfs-authenticate repo2 download\nstdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'\nsoft git-lfs-authenticate repo2 upload\nstdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'\nsoft git-lfs-authenticate repo2p download\nstdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'\nsoft git-lfs-authenticate repo2p upload\nstdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'\n\n# SSH Git commands as user\n! usoft git-upload-pack\ncmp stderr argserr1.txt\n! usoft git-upload-pack foobar\ncmp stderr invalidrepoerr.txt\n! usoft git-upload-archive\ncmp stderr argserr1.txt\n! usoft git-upload-archive foobar\ncmp stderr invalidrepoerr.txt\n! usoft git-receive-pack\ncmp stderr argserr1.txt\n! usoft git-receive-pack foobar\nstdout '.*0000 capabilities.*git.*' # git pack response\nstderr '.*something went wrong.*'\n! usoft git-lfs-authenticate\ncmp stderr argserr2.txt\n! usoft git-lfs-authenticate foobar download\ncmp stderr invalidrepoerr.txt\n! usoft git-lfs-authenticate foobar upload\ncmp stderr invalidrepoerr.txt\nusoft git-lfs-authenticate repo1 download\nstdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'\n! usoft git-lfs-authenticate repo1 upload\ncmp stderr notauthorizederr.txt\n! usoft git-lfs-authenticate repo1p download\ncmp stderr notauthorizederr.txt\n! usoft git-lfs-authenticate repo1p upload\ncmp stderr notauthorizederr.txt\nusoft git-lfs-authenticate repo2 download\nstdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'\nusoft git-lfs-authenticate repo2 upload\nstdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'\nusoft git-lfs-authenticate repo2p download\nstdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'\nusoft git-lfs-authenticate repo2p upload\nstdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'\n\n# stop the server\n[windows] stopserver\n[windows] ! stderr .\n\n-- argserr1.txt --\nError: accepts 1 arg(s), received 0\n-- argserr2.txt --\nError: accepts 2 arg(s), received 0\n-- argserr3.txt --\nError: accepts 2 arg(s), received 1\n-- invalidrepoerr.txt --\nError: invalid repo\n-- notauthorizederr.txt --\nError: you are not authorized to do this\n"
  },
  {
    "path": "testscript/testdata/token.txtar",
    "content": "# vi: set ft=conf\n\n# start soft serve\nexec soft serve &\n# wait for SSH server to start\nensureserverrunning SSH_PORT\n\n# create user\nsoft user create user1 --key \"$USER1_AUTHORIZED_KEY\"\n\n# generate jwt token\nusoft token create 'test1'\nstdout 'ss_.*'\nstderr 'Access token created'\nusoft token create --expires-in 1y 'test2'\nstdout 'ss_.*'\nstderr 'Access token created'\nusoft token create --expires-in 1ns 'test3'\nstdout 'ss_.*'\nstderr 'Access token created'\n\n# list tokens\nusoft token list\ncp stdout tokens.txt\ngrep '1.*test1.*-' tokens.txt\ngrep '2.*test2.*1 year from now' tokens.txt\ngrep '3.*est3.*expired' tokens.txt\n\n# delete token\nusoft token delete 1\nstderr 'Access token deleted'\n! usoft token delete 1\nstderr 'token not found'\n\n# stop the server\n[windows] stopserver\n"
  },
  {
    "path": "testscript/testdata/ui-home.txtar",
    "content": "# vi: set ft=conf\n\n# start soft serve\nexec soft serve &\n# wait for SSH server to start\nensureserverrunning SSH_PORT\n\n# test repositories tab\nui '\"    q\"'\ncp stdout home.txt\ngrep 'Test Soft Serve' home.txt\ngrep '• Repositories' home.txt\ngrep 'No items' home.txt\n\n# test about tab\nui '\"\\t    q\"'\ncp stdout about.txt\ngrep 'Create a `.soft-serve` repository and add a `README.md` file' about.txt\n\n# add a new repo\nsoft repo create .soft-serve -n 'Config' -d '\"Test Soft Serve\"'\nsoft repo description .soft-serve\nstdout 'Test Soft Serve'\nsoft repo project-name .soft-serve\nstdout 'Config'\n\n# clone repo\ngit clone ssh://localhost:$SSH_PORT/.soft-serve config\n\n# create readme file\nmkfile ./config/README.md '# Hello World\\nTest Soft Serve'\ngit -C config add -A\ngit -C config commit -m 'Initial commit'\ngit -C config push origin HEAD\n\n# test repositories tab\nui '\"    q\"'\ncp stdout home2.txt\ngrep 'Config' home2.txt\ngrep 'Test Soft Serve' home2.txt\ngrep 'git clone ssh://localhost:.*/.soft-serve' home2.txt\n\n# test about tab\nui '\"\\t      q\"'\ncp stdout about2.txt\ngrep '• About' about2.txt\ngrep 'Hello World' about2.txt\ngrep 'Test Soft Serve' about2.txt\n\n# stop the server\n[windows] stopserver\n[windows] ! stderr .\n\n"
  },
  {
    "path": "testscript/testdata/user_management.txtar",
    "content": "# vi: set ft=conf\n\n# convert crlf to lf on windows\n[windows] dos2unix info.txt admin_key_list1.txt admin_key_list2.txt list1.txt list2.txt foo_info1.txt foo_info2.txt foo_info3.txt foo_info4.txt foo_info5.txt\n\n# start soft serve\nexec soft serve &\n# wait for SSH server to start\nensureserverrunning SSH_PORT\n\n# add key to admin\nsoft user add-pubkey admin \"$ADMIN2_AUTHORIZED_KEY\"\nsoft user info admin\nsoft info\ncmpenv stdout info.txt\n\n\n# list admin pubkeys\nsoft pubkey list\ncmpenv stdout admin_key_list1.txt\n\n# remove key\nsoft pubkey remove $ADMIN2_AUTHORIZED_KEY\nsoft pubkey list\ncmpenv stdout admin_key_list2.txt\n\n# add key back key\nsoft pubkey add $ADMIN2_AUTHORIZED_KEY\nsoft pubkey list\ncmpenv stdout admin_key_list1.txt\n\n# list users\nsoft user list\ncmpenv stdout list1.txt\n\n# create a new user\nsoft user create foo --key \"$USER1_AUTHORIZED_KEY\"\nsoft user list\ncmpenv stdout list2.txt\n\n# get new user info\nsoft user info foo\ncmpenv stdout foo_info1.txt\n\n# make user admin\nsoft user set-admin foo true\nsoft user info foo\ncmpenv stdout foo_info2.txt\n\n# remove admin\nsoft user set-admin foo false\nsoft user info foo\ncmpenv stdout foo_info3.txt\n\n# remove key from user\nsoft user remove-pubkey foo \"$USER1_AUTHORIZED_KEY\"\nsoft user info foo\ncmpenv stdout foo_info4.txt\n\n# rename user\nsoft user set-username foo foo2\nsoft user info foo2\ncmpenv stdout foo_info5.txt\n\n# remove user\nsoft user delete foo2\n! stdout .\nsoft user list\ncmpenv stdout list1.txt\n\n# stop the server\n[windows] stopserver\n[windows] ! stderr .\n\n\n-- info.txt --\nUsername: admin\nAdmin: true\nPublic keys:\n  $ADMIN1_AUTHORIZED_KEY\n  $ADMIN2_AUTHORIZED_KEY\n-- list1.txt --\nadmin\n-- list2.txt --\nadmin\nfoo\n-- foo_info1.txt --\nUsername: foo\nAdmin: false\nPublic keys:\n  $USER1_AUTHORIZED_KEY\n-- foo_info2.txt --\nUsername: foo\nAdmin: true\nPublic keys:\n  $USER1_AUTHORIZED_KEY\n-- foo_info3.txt --\nUsername: foo\nAdmin: false\nPublic keys:\n  $USER1_AUTHORIZED_KEY\n-- foo_info4.txt --\nUsername: foo\nAdmin: false\nPublic keys:\n-- foo_info5.txt --\nUsername: foo2\nAdmin: false\nPublic keys:\n-- admin_key_list1.txt --\n$ADMIN1_AUTHORIZED_KEY\n$ADMIN2_AUTHORIZED_KEY\n-- admin_key_list2.txt --\n$ADMIN1_AUTHORIZED_KEY\n"
  }
]