Full Code of nrjdalal/gitpick for AI

main 0582f18df49d cached
33 files
173.2 KB
51.9k tokens
63 symbols
1 requests
Download .txt
Repository: nrjdalal/gitpick
Branch: main
Commit: 0582f18df49d
Files: 33
Total size: 173.2 KB

Directory structure:
gitextract_ijhrzzvx/

├── .github/
│   ├── FUNDING.yml
│   └── workflows/
│       ├── dependencies.yml
│       ├── release.yml
│       └── test.yml
├── .gitignore
├── .lintstagedrc.json
├── .oxfmtrc.jsonc
├── .oxlintrc.jsonc
├── CHANGELOG.md
├── LICENSE
├── README.md
├── bin/
│   ├── external/
│   │   ├── nano-spawn.ts
│   │   ├── speed-highlight.ts
│   │   ├── strip-json-comments.ts
│   │   ├── yocto-spinner.ts
│   │   └── yoctocolors.ts
│   ├── index.ts
│   └── utils/
│       ├── clone-action.ts
│       ├── copy-dir.ts
│       ├── get-default-branch.ts
│       ├── interactive-picker.ts
│       ├── parse-time-string.ts
│       ├── transform-url.ts
│       ├── update-notifier.ts
│       └── use-config.ts
├── lefthook.yml
├── package.json
├── tests/
│   ├── cli.test.ts
│   ├── fixtures.jsonc
│   └── tree.mjs
├── tsconfig.json
├── tsdown.config.ts
└── types/
    └── package.json.d.ts

================================================
FILE CONTENTS
================================================

================================================
FILE: .github/FUNDING.yml
================================================
github: nrjdalal


================================================
FILE: .github/workflows/dependencies.yml
================================================
name: Update Dependencies

on:
  schedule:
    - cron: "0 0 * * *"

concurrency: ${{ github.workflow }}-${{ github.ref }}

permissions:
  contents: write
  pull-requests: write

jobs:
  update:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Repository
        uses: actions/checkout@v6
        with:
          fetch-depth: 0

      - name: Setup Bun
        uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest

      - name: Update Dependencies
        run: |
          git config user.name 'github-actions[bot]'
          git config user.email 'github-actions[bot]@users.noreply.github.com'
          git pull origin main
          git checkout -B update-dependencies
          bun update
          bun i
          git add package.json bun.lock
          git commit -m "chore(deps): update dependencies" || true
          git push -f origin update-dependencies
          curl -sL -X POST -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}"  "https://api.github.com/repos/$GITHUB_REPOSITORY/pulls" \
            -d "{\"title\": \"chore(deps): update dependencies\", \"head\": \"update-dependencies\", \"base\": \"main\", \"body\": \"Automatically generated PR to update dependencies.\"}"


================================================
FILE: .github/workflows/release.yml
================================================
name: Release Package

on:
  push:
    branches:
      - "**"
  issue_comment:
    types: [created]

concurrency: ${{ github.workflow }}-${{ github.ref }}

permissions:
  id-token: write
  contents: write
  statuses: write
  pull-requests: write

jobs:
  test:
    if: github.event_name == 'push'
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - name: Checkout Repository
        uses: actions/checkout@v6

      - name: Setup Bun
        uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest

      - name: Install Dependencies
        run: bun install --frozen-lockfile

      - name: Test
        run: bun run test

  release:
    if: |
      always() && !failure() && !cancelled() && (
        github.event_name == 'push' ||
        github.event_name == 'issue_comment' && github.event.comment.user.login == github.repository_owner && contains(github.event.comment.body, 'release')
      )
    needs: [test]
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Repository
        uses: actions/checkout@v6
        with:
          fetch-depth: 0

      - name: Setup Node
        uses: actions/setup-node@v6
        with:
          node-version: lts/*
          registry-url: https://registry.npmjs.org

      - name: Setup Bun
        uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest

      - name: Release Package
        run: |
          PACKAGE_NAME=$(bunx json -f package.json -a name)
          npm view "$PACKAGE_NAME" 2>/dev/null || { echo "$PACKAGE_NAME does not exist in the npm registry. Skipping publish."; exit 0; }
          PACKAGE_VERSION=$(bunx json -f package.json -a version)
          VERSIONS=$(npm view $PACKAGE_NAME dist-tags --json)
          LATEST_VERSION=$(echo $VERSIONS | bunx json latest)
          if [[ $GITHUB_EVENT_NAME == 'issue_comment' ]]; then
            PR_NUMBER=$(echo "${{ github.event.issue.pull_request.url }}" | grep -o '[0-9]*$')
            LATEST_COMMIT_SHA=$(curl -fsSL -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" "https://api.github.com/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/commits" | bunx json -a sha | tail -n 1)
            git checkout $LATEST_COMMIT_SHA
            TAG="test"
            RELEASE_VERSION="0.0.0-${LATEST_COMMIT_SHA:0:7}"
          elif [[ $PACKAGE_VERSION != $LATEST_VERSION ]]; then
            RELEASE_VERSION=$PACKAGE_VERSION
            HAS_TAG=$(echo $PACKAGE_VERSION | grep -o '[a-zA-Z]*' | head -n 1)
            TAG=$([[ -n "$HAS_TAG" ]] && echo $HAS_TAG || echo "latest")
          else
            TAG="canary"
            RELEASE_VERSION=$(bunx semver $LATEST_VERSION -i minor)
            TAGGED_VERSION=$(echo $VERSIONS | bunx json $TAG)
            RELEASE_VERSION=$([[ $TAGGED_VERSION == $RELEASE_VERSION* ]] && bunx semver $TAGGED_VERSION -i prerelease || echo $RELEASE_VERSION-$TAG.0)
          fi

          bunx json -I -f package.json -e "this.version=\"$RELEASE_VERSION\""
          bun install --frozen-lockfile
          bun run build
          bunx json -I -f package.json -e 'delete this.scripts'
          bunx json -I -f package.json -e 'delete this.commitlint'
          bunx json -I -f package.json -e 'delete this.devDependencies'
          npm publish --provenance --access public --no-git-checks --tag $TAG

          PACKAGE_URL="https://www.npmjs.com/package/$PACKAGE_NAME/v/$RELEASE_VERSION"
          if [[ $GITHUB_EVENT_NAME == 'issue_comment' ]]; then
            curl -fsSL -X DELETE -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" "https://api.github.com/repos/$GITHUB_REPOSITORY/issues/comments/${{ github.event.comment.id }}" >/dev/null \
              && echo "🟢 Releasing comment deleted!" || echo "🔴 Failed to delete releasing comment."
            curl -fsSL -X POST -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" "https://api.github.com/repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \
              -d "{\"body\": \"Test package released - [\`$PACKAGE_NAME@$RELEASE_VERSION\`]($PACKAGE_URL)\"}" >/dev/null \
              && echo "🟢 Release comment to PR added!" || echo "🔴 Failed to add release comment to PR."
          else
            curl -fsSL -X POST -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" "https://api.github.com/repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/comments" \
              -d "{\"body\": \"Package released - [\`$PACKAGE_NAME@$RELEASE_VERSION\`]($PACKAGE_URL)\"}" >/dev/null \
              && echo "🟢 Release comment added!" || echo "🔴 Failed to add release comment."
            curl -fsSL -X POST -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" "https://api.github.com/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \
              -d "{\"state\": \"success\", \"context\": \"Package released\", \"description\": \"$PACKAGE_NAME@$RELEASE_VERSION\", \"target_url\": \"$PACKAGE_URL\"}" >/dev/null \
              && echo "🟢 Release status updated!" || echo "🔴 Failed to update release status."
          fi
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}


================================================
FILE: .github/workflows/test.yml
================================================
name: Test

on:
  push:
    branches:
      - "**"

jobs:
  test:
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - name: Checkout Repository
        uses: actions/checkout@v6

      - name: Setup Bun
        uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest

      - name: Install Dependencies
        run: bun install --frozen-lockfile

      - name: Test
        run: bun run test


================================================
FILE: .gitignore
================================================
# os
.DS_Store
*.pem

# logs
/*-debug.log*
/*-error.log*

# lockfiles
yarn.lock
package-lock.json
pnpm-lock.yaml
bun.lockb

# npm
node_modules/

# build
dist/

# custom
.test-artifacts/


================================================
FILE: .lintstagedrc.json
================================================
{
  "*": ["oxfmt --no-error-on-unmatched-pattern", "oxlint"]
}


================================================
FILE: .oxfmtrc.jsonc
================================================
{
  "$schema": "./node_modules/oxfmt/configuration_schema.json",
  "semi": false,
  "experimentalSortImports": {},
}


================================================
FILE: .oxlintrc.jsonc
================================================
{
  "$schema": "./node_modules/oxlint/configuration_schema.json",
}


================================================
FILE: CHANGELOG.md
================================================
# Changelog

## v5.4.0 (2026-03-22)

- **Local directory interactive mode** - browse local directories with `gitpick -i`
  - `gitpick -i` browses cwd
  - `gitpick -i target` browses cwd, copies selected to target
  - `gitpick ./path -i target` browses a specific path
- Uses `git ls-files` to respect `.gitignore` when in a git repo
- Falls back to manual walk (skipping `.git` only) outside git repos
- Preserves symlinks when copying (uses `lstat` + `symlink`)
- Warns on symlink copy failures instead of swallowing silently
- Errors on target-inside-source and missing source with explicit target

## v5.3.0 (2026-03-22)

- **File preview** - press enter on a file to view its content with line numbers and cursor navigation
- **Syntax highlighting** - 38 languages via vendored `@speed-highlight/core` (zero runtime deps)
- File sizes shown right-aligned in tree view (files and folders)
- Full file path shown in preview header
- Enter on symlink-to-folder jumps cursor to target and expands ancestors
- Relative symlink paths resolved correctly for preview and selection
- Ctrl-C works in preview mode
- Terminal resize handled correctly in preview mode
- ANSI-aware line truncation prevents color bleed

## v5.2.0 (2026-03-22)

- Symlinks counted separately and shown in yellow in footer
- Selecting a symlink also selects its target file/folder
- Deselecting all children auto-deselects parent folder
- Press `.` toggles select all from anywhere
- Show "press . to select all" when nothing selected, "all selected" with size when everything selected
- Add interactive mode screenshot to README

## v5.1.0 (2026-03-22)

- Show symlinks in interactive picker (yellow with `->` target, matching dry-run tree)
- Show "all selected" status when everything is selected
- Scope interactive picker to subpath when using tree URLs (e.g. `owner/repo/tree/main/src -i`)
- Re-render interactive picker on terminal resize

## v5.0.0 (2026-03-22)

- **Interactive mode (`-i` / `--interactive`)** - browse any repo's file tree in your terminal and cherry-pick exactly the files and folders you want
  - Hierarchical tree view with expand/collapse, multi-select, select all
  - Keyboard navigation: arrow keys, `j`/`k`, `h`/`l`, space, enter, `c` to confirm, `q` to quit
  - Shows selection stats: folder/file count, total size, scroll position
  - Dark gray background highlight on current line, `●`/`○` selection indicators
  - Auto-expands all levels for small repos (<=30 entries), 2 levels for larger ones
  - Respects `--dry-run`, `--overwrite`, `--recursive`, `--tree`
  - Graceful TTY guard, SIGINT cleanup, alternate screen buffer
  - Works with GitHub, GitLab, Bitbucket, public and private repos
  - Zero new dependencies
- Add interactive mode tests (TTY guard, help text)
- Update README with interactive mode docs, table of contents, controls reference

## v4.19.0 (2026-03-22)

- **Environment variable token support** — `GITHUB_TOKEN`/`GH_TOKEN`, `GITLAB_TOKEN`, `BITBUCKET_TOKEN` auto-detected for private repos without embedding tokens in URLs
- **SIGINT/SIGTERM temp dir cleanup** — signal handlers clean up active temp directories on process kill
- **Non-TTY spinner suppression** — spinner animation completely suppressed in CI/piped output
- **`--verbose` now includes stats** — file count with size, network/copy/total time breakdown
- Non-blocking update notifier — checks npm registry in background, shows notice on next run

## v4.18.0 (2026-03-22)

- **Add `--quiet` / `-q` flag** — suppress all output except errors, ideal for CI pipelines and scripts
- **Add `--verbose` flag** — show detailed clone info: strategy (shallow/full), source URL, target path, file count, duration
- Update banner to two lines for readability
- Add `dim` color formatter
- Reorder README features list

## v4.17.0 (2026-03-22)

- **Add `--tree` flag** — display cloned file structure as a colored tree (like the `tree` command)
  - Bold cyan for root directory, cyan for subdirs, yellow for symlinks, cyan/white for symlink targets
  - Smart path header: `./` for cwd-relative, `~/` for home-relative, absolute otherwise
  - Works with `--dry-run` (clones to temp dir, prints tree, cleans up)
  - Consistent output for repos, folders, and blobs
- `copyDir` now returns list of copied file paths
- `cloneAction` returns `CloneResult` with files and duration
- Fix oxlint warnings in test suite
- Update package sizes in README

## v4.16.2 (2026-03-21)

- Add CLI help examples for `--dry-run`, GitLab and Bitbucket
- Update README with package sizes, multi-host config examples, and verified adopters

## v4.16.1 (2026-03-21)

- Add `--dry-run`, GitLab and Bitbucket examples to CLI help output
- Update README with package sizes and multi-host config examples

## v4.16.0 (2026-03-21)

- **Multi-host support** — clone from GitLab and Bitbucket in addition to GitHub
- Add `--dry-run` / `-n` flag to preview what would be cloned without cloning
- Add `/commit/` URL support — `owner/repo/commit/SHA` correctly extracts the commit SHA
- Add `refs/remotes` and `refs/tags` support for raw URLs (in addition to `refs/heads`)
- Preserve shorthand raw URL parsing (`owner/repo/refs/heads/branch/file`)
- Migrate test suite from bash scripts to bun:test (106 tests across dry-run, clone, config, integrity)
- Gate releases behind passing tests in CI
- Fix `pull_request.url` reference in release workflow

## v4.15.0 (2026-03-21)

- **34KB → 19KB (43% smaller), zero dependencies**
- Add `.gitpick.json` / `.gitpick.jsonc` config file support — pick multiple files, folders, branches in one command
- Internalize all external dependencies (terminal-link, yocto-spinner, yoctocolors, nano-spawn, strip-json-comments)
- Add symlink support in `copyDir`
- Fix Windows blob/tree path handling (split on both `/` and `\`, use `path.dirname`)
- Add cross-platform CI (ubuntu, macos, windows)
- Add PowerShell test suite for Windows backslash paths
- Migrate from prettier to oxfmt, simple-git-hooks to lefthook, add oxlint
- Move tests to `tests/` dir with cross-platform `tree.mjs` for file tree output

## v4.14.0 (2026-03-21)

- Add symlink support — `copyDir` now preserves symlinks instead of failing or following them
- Migrate from prettier to oxfmt/oxlint
- Migrate from simple-git-hooks to lefthook
- Clean up release workflow

## v4.13.0 (2026-03-21)

- Update build tooling dependencies
- Update package size in readme
- Add package runner support in test script

## v4.12.4 (2026-03-15)

- Update dependency @types/node to v25
- Update build tooling dependencies
- Fix typo in related projects section

## v4.12.3 (2025-10-22)

- Documentation updates

## v4.12.2 (2025-09-19)

- Fix Twitter badge link in README

## v4.12.1 (2025-09-19)

- Documentation and package.json updates

## v4.12.0 (2025-08-26)

- Update dependencies

## v4.11.0 – v4.11.3 (2025-05-07 – 2025-06-02)

- Dependency updates
- Documentation improvements

## v4.10.0 – v4.10.2 (2025-04-06 – 2025-04-30)

- Internal improvements
- Dependency updates

## v4.9.0 (2025-04-06)

- Update external dependencies
- Documentation improvements

## v4.8.0 – v4.8.1 (2025-04-04)

- CLI and documentation improvements

## v4.7.0 – v4.7.1 (2025-04-04)

- CLI improvements
- Clone action refinements

## v4.6.0 – v4.6.1 (2025-04-04)

- Refactor clone action, URL transform, and time parsing utilities
- CI workflow updates

## v4.5.0 – v4.5.3 (2025-04-03)

- External dependency management improvements
- CLI refinements

## v4.4.0 – v4.4.3 (2025-04-03)

- Clone action improvements

## v4.3.0 – v4.3.2 (2025-04-03)

- Extend commit clone capability
- External dependency updates

## v4.2.0 – v4.2.1 (2025-04-03)

- Major internal refactor
- Test infrastructure improvements

## v4.1.0 – v4.1.1 (2025-04-02)

- Dependency updates
- CI improvements

## v4.0.0 (2025-04-02)

- Major rewrite — new architecture with external vendored dependencies
- Zero runtime dependencies
- Faster cloning with shallow clone + sparse checkout
- Support for shorthands (`owner/repo`), full URLs, and SSH URLs
- Clone repos, trees, blobs, branches, commits, and raw content
- Watch mode (`--watch`) for continuous syncing
- Private repo support via PAT tokens
- Windows long path support

## v3.17.0 – v3.26.0 (2025-03-29 – 2025-03-30)

- Fix Win32 long paths support (#6)
- Logging improvements
- Switch from inquirer/ora to clack
- Add overwrite alias (`-o` for `--force`)
- Documentation updates


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2025 Neeraj Dalal

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
# GitPick

<!--  -->

**Clone exactly what you need aka straightforward project scaffolding!**

[![Twitter](https://img.shields.io/twitter/follow/nrjdalal_dev?label=%40nrjdalal_dev)](https://twitter.com/nrjdalal_dev)
[![npm](https://img.shields.io/npm/v/gitpick?color=red&logo=npm)](https://www.npmjs.com/package/gitpick)
[![downloads](https://img.shields.io/npm/dt/gitpick?color=red&logo=npm)](https://www.npmjs.com/package/gitpick)
[![stars](https://img.shields.io/github/stars/nrjdalal/gitpick?color=blue)](https://github.com/nrjdalal/gitpick)

📦 `Zero dependencies` / `Un/packed (~67/25kb)` / `Faster and more features` yet drop-in replacement for `degit`

> #### Just `copy-and-paste` any GitHub, GitLab, Bitbucket or Codeberg URL - no editing required (shorthands work too) - to clone individual files, folders, branches, commits, raw content or even entire repositories without the `.git` directory.

Unlike other tools that force you to tweak URLs or follow strict formats to clone files, folders, branches or commits GitPick works seamlessly with any URL.

**You can also try [Interactive Mode](#-interactive-mode)**. Browse any repo right in your terminal. See every file, pick what you want, skip what you don't. Just `gitpick owner/repo -i` and you're in. No more guessing paths.

<img width="400" alt="GitPick Meme" src="https://github.com/user-attachments/assets/180c3e5b-320c-48d7-aaf9-a7402e74c882" />

---

### Table of Contents

- [Some Examples](#-some-examples)
- [Features](#-features)
- [Quick Usage](#-quick-usage)
- [Options](#-options)
- [Interactive Mode](#-interactive-mode)
- [Private Repos](#-private-repos)
- [Config File](#-config-file)
- [Install Globally](#-install-globally-optional)
- [Used By](#-used-by)
- [Related Projects](#-related-projects)
- [Contributing](#-contributing)

---

## 📖 Some Examples

### See [Quick Usage](#-quick-usage) for to learn more.

```sh
# interactive mode - browse and pick files/folders
npx gitpick owner/repo -i
npx gitpick https://github.com/owner/repo -i
# clone a repo without .git
npx gitpick owner/repo
npx gitpick https://github.com/owner/repo
# clone a folder aka tree
npx gitpick owner/repo/tree/main/path/to/folder
npx gitpick https://github.com/owner/repo/tree/main/path/to/folder
# clone a file aka blob
npx gitpick owner/repo/blob/main/path/to/file
npx gitpick https://github.com/owner/repo/blob/main/path/to/file
# clone a branch
npx gitpick owner/repo -b canary
npx gitpick https://github.com/owner/repo -b canary
npx gitpick owner/repo/tree/canary
npx gitpick https://github.com/owner/repo/tree/canary
# clone a commit SHA
npx gitpick owner/repo -b cc8e93
npx gitpick https://github.com/owner/repo/commit/cc8e93
# clone submodules
npx gitpick owner/repo -r
npx gitpick https://github.com/owner/repo -r
# clone a private repo
npx gitpick https://<token>@github.com/owner/repo
# clone from GitLab
npx gitpick https://gitlab.com/owner/repo
npx gitpick https://gitlab.com/owner/repo/-/tree/main/path/to/folder
# clone from Bitbucket
npx gitpick https://bitbucket.org/owner/repo
npx gitpick https://bitbucket.org/owner/repo/src/main/path/to/folder
# clone from Codeberg
npx gitpick https://codeberg.org/owner/repo
npx gitpick https://codeberg.org/owner/repo/src/branch/main/path/to/folder
# dry run (preview without cloning)
npx gitpick owner/repo --dry-run
npx gitpick owner/repo -i --dry-run
```

---

## ✨ Features

- 🔍 Clone individual files or folders from GitHub, GitLab, Bitbucket and Codeberg
- 🧠 Use shorthands `TanStack/router` or full URL's `https://github.com/TanStack/router`
- ⚙️ Auto-detects branches and target directory (if not specified) like `git clone`
- **🔥 Interactive mode** - browse and cherry-pick files/folders with `-i` | `--interactive`
- 🔐 Seamlessly works with both public and private repositories using a PAT
- 📦 Can easily clone all submodules with `-r` | `--recursive`
- 🔎 Preview what would be cloned with `--dry-run` before cloning
- 🌳 View cloned file structure as a colored tree with `--tree`
- 🗑️ Overwrite or replace existing files without a prompt using `-o` | `--overwrite`
- 🔁 Sync changes remotely with `--watch` using intervals (e.g., `15s`, `1m`, `1h`)
- 🔇 Silent mode with `--quiet` for CI pipelines, debug mode with `--verbose`
- 📋 Config file support (`.gitpick.json` / `.gitpick.jsonc`) for multi-path picks

---

## 🚀 Quick Usage

```sh
npx gitpick <url/shorthand> [target] [options]
```

- [target] and [options] are optional, if not specified, GitPick fallbacks to the default behavior of `git clone`

Examples:

```sh
npx gitpick https://github.com/owner/repo           # repo without .git
npx gitpick owner/repo/tree/main/path/to/folder     # a folder aka tree
npx gitpick owner/repo/blob/main/path/to/file       # a file aka blob

npx gitpick <url/shorthand>                         # default git behavior
npx gitpick <url/shorthand> [target]                # with optional target
npx gitpick <url/shorthand> -b [branch/SHA]         # branch or commit SHA
npx gitpick <url/shorthand> -o                      # overwrite if exists
npx gitpick <url/shorthand> -r                      # clone submodules
npx gitpick <url/shorthand> -w 30s                  # sync every 30 seconds
npx gitpick <url/shorthand> --dry-run               # preview without cloning
npx gitpick https://<token>@github.com/owner/repo   # private repository
npx gitpick https://gitlab.com/owner/repo           # GitLab
npx gitpick https://bitbucket.org/owner/repo        # Bitbucket
npx gitpick https://codeberg.org/owner/repo         # Codeberg
```

<img width="720" alt="Image" src="https://github.com/user-attachments/assets/ddbc41b4-bfc6-4287-bb85-eb949d723591" />

---

## 🔧 Options

```
-b, --branch       Branch/SHA to clone
-i, --interactive  Browse and pick files/folders interactively
-n, --dry-run      Show what would be cloned without cloning
-o, --overwrite    Skip overwrite prompt
-r, --recursive    Clone submodules
-w, --watch [time] Watch the repository and sync every [time]
                   (e.g. 1h, 30m, 15s)
    --tree         List copied files as a tree
-q, --quiet        Suppress all output except errors
    --verbose      Show detailed clone information
-h, --help         display help for command
-v, --version      display the version number
```

---

## 🔥 Interactive Mode

> **New in v5.0.** Browse any repository's file tree in your terminal and cherry-pick exactly the files and folders you want.

```sh
npx gitpick owner/repo -i
npx gitpick owner/repo -i -b canary
npx gitpick https://github.com/owner/repo -i
npx gitpick https://gitlab.com/owner/repo -i
npx gitpick https://codeberg.org/owner/repo -i
```

<img width="720" alt="Interactive Mode" src="https://github.com/user-attachments/assets/9d6f4db7-ed84-4783-b815-0267719b3a52" />

Navigate with arrow keys, select with space, expand/collapse with enter, `.` to select all, `c` to confirm. Works with GitHub, GitLab, Bitbucket, Codeberg, public and private repos.

---

## 🔐 Private Repos

Use a personal access token with read-only contents permission. Works with GitHub, GitLab, Bitbucket and Codeberg:

```sh
npx gitpick https://<token>@github.com/owner/repo
npx gitpick https://<token>@gitlab.com/owner/repo
npx gitpick https://<token>@bitbucket.org/owner/repo
npx gitpick https://<token>@codeberg.org/owner/repo
```

Or use environment variables (recommended for CI):

```sh
export GITHUB_TOKEN=ghp_xxxx    # or GH_TOKEN
export GITLAB_TOKEN=glpat-xxxx
export BITBUCKET_TOKEN=xxxx
export CODEBERG_TOKEN=xxxx

npx gitpick owner/private-repo  # token is picked up automatically
```

Create a GitHub token 👉 [here](https://github.com/settings/personal-access-tokens/new) with `repo -> contents: read-only` permission.

---

## 📋 Config File

Create a `.gitpick.json` or `.gitpick.jsonc` in your project to pick multiple files/folders in one command:

```jsonc
// .gitpick.jsonc
[
  // clone a repo without .git
  "owner/repo",
  "https://github.com/owner/repo",
  // clone a folder aka tree
  "owner/repo/tree/main/path/to/folder",
  "https://github.com/owner/repo/tree/main/path/to/folder",
  // clone a file aka blob
  "owner/repo/blob/main/path/to/file",
  "https://github.com/owner/repo/blob/main/path/to/file",
  // clone a branch
  "owner/repo -b canary",
  "https://github.com/owner/repo -b canary",
  "owner/repo/tree/canary",
  "https://github.com/owner/repo/tree/canary",
  // clone a commit SHA
  "owner/repo -b cc8e93",
  "https://github.com/owner/repo/commit/cc8e93",
  // clone submodules
  "owner/repo -r",
  "https://github.com/owner/repo -r",
  // clone a private repo
  "https://<token>@github.com/owner/repo",
  // GitLab
  "https://gitlab.com/owner/repo",
  "https://gitlab.com/owner/repo/-/tree/main/path/to/folder",
  // Bitbucket
  "https://bitbucket.org/owner/repo",
  "https://bitbucket.org/owner/repo/src/main/path/to/folder",
  // Codeberg
  "https://codeberg.org/owner/repo",
  "https://codeberg.org/owner/repo/src/branch/main/path/to/folder",
]
```

Then just run:

```sh
npx gitpick
```

Each entry follows the same `<url> [target]` syntax as the CLI. All entries are cloned with `-o` (overwrite) by default.

---

## 📦 Install Globally (Optional)

```sh
npm install -g gitpick
gitpick <url/shorthand> [target] [options]
```

---

## 🌍 Used By

- **Major:** [Storybook](https://github.com/storybookjs/storybook), [TanStack Router](https://github.com/TanStack/router), [ElectricSQL](https://github.com/electric-sql/electric), [Alchemy](https://github.com/alchemy-run/alchemy), [Porto](https://github.com/ithacaxyz/porto), [oidc-spa](https://github.com/keycloakify/oidc-spa), [Fidely UI](https://github.com/fidely-ui/fidely-ui)
- **Other:** [hono-better-auth](https://github.com/LovelessCodes/hono-better-auth), [vite-hono-ssr](https://github.com/Mirza-Glitch/vite-hono-ssr), [tanstack-start-cf](https://github.com/depsimon/tanstack-start-cf), [constructa-starter-min](https://github.com/instructa/constructa-starter-min), [tanstack-starter](https://github.com/enesdir/tanstack-starter), [react-shadcn-starter](https://github.com/aliadelelroby/react-shadcn-starter), [open-store](https://github.com/bang0711/open-store)

---

## 🛠 More Tools

Check out more projects at [github.com/nrjdalal](https://github.com/nrjdalal)

## 🔗 Related Projects

- [tiged](https://github.com/tiged/tiged) - community driven fork of degit
- [giget](https://github.com/unjs/giget) - alternative approach

[![Star History Chart](https://api.star-history.com/svg?repos=nrjdalal/gitpick,tiged/tiged,unjs/giget&type=timeline&logscale&legend=top-left)](https://www.star-history.com/#nrjdalal/gitpick&tiged/tiged&unjs/giget&type=timeline&logscale&legend=top-left)

## 🤝 Contributing

Contributions welcome - any help is appreciated!

- Fork the repo and create a branch (use descriptive names, e.g. feat/<name> or fix/<name>).
- Make your changes, add tests if applicable, and run the checks:
  - bun install
  - bun test
- Follow the existing code style and commit message conventions (use conventional commits: feat, fix, docs, chore).
- Open a PR describing the change, motivation, and any migration notes; link related issues.
- For breaking changes or large features, open an issue first to discuss the approach.
- By contributing you agree to the MIT license and the project's Code of Conduct.

Thank you for helping improve GitPick!

## 📄 License

MIT – [LICENSE](LICENSE)


================================================
FILE: bin/external/nano-spawn.ts
================================================
// Trimmed from nano-spawn by Sindre Sorhus (https://github.com/sindresorhus/nano-spawn)
import { spawn as nodeSpawn, type SpawnOptions } from "node:child_process"
import { once } from "node:events"
import fs from "node:fs/promises"
import path from "node:path"
import process from "node:process"
import { fileURLToPath } from "node:url"
import { stripVTControlCharacters } from "node:util"

class SubprocessError extends Error {
  name = "SubprocessError"
  stdout = ""
  stderr = ""
  exitCode?: number
}

const exeExtensions = [".exe", ".com"]

const EXE_MEMO: Record<string, Promise<boolean>> = {}
const memoize =
  (fn: (...args: string[]) => Promise<boolean>) =>
  (...args: string[]) =>
    (EXE_MEMO[args.join("\0")] ??= fn(...args))

const access = memoize(async (...args: string[]) => {
  try {
    await fs.access(args[0])
    return true
  } catch {
    return false
  }
})

const mIsExe = memoize(async (file: string, cwd: string, PATH: string) => {
  const parts = PATH.split(path.delimiter)
    .filter(Boolean)
    .map((part) => part.replace(/^"(.*)"$/, "$1"))
  try {
    await Promise.any(
      [cwd, ...parts].flatMap((part) =>
        exeExtensions.map((ext) => access(`${path.resolve(part, file)}${ext}`)),
      ),
    )
  } catch {
    return false
  }
  return true
})

const shouldForceShell = async (file: string, options: SpawnOptions): Promise<boolean> =>
  process.platform === "win32" &&
  !options.shell &&
  !exeExtensions.some((ext) => file.toLowerCase().endsWith(ext)) &&
  !(await mIsExe(
    file,
    (options.cwd as string) ?? ".",
    (process.env.PATH || process.env.Path) ?? "",
  ))

const escapeFile = (file: string) => file.replaceAll(/([()\][%!^"`<>&|;, *?])/g, "^$1")
const escapeArgument = (arg: string) =>
  escapeFile(escapeFile(`"${arg.replaceAll(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1")}"`))

const getCommandPart = (part: string) =>
  /[^\w./-]/.test(part) ? `'${part.replaceAll("'", "'\\''")}'` : part

export default async function spawn(
  file: string,
  args: string[] = [],
  options: Record<string, any> = {},
): Promise<{ stdout: string; stderr: string }> {
  const {
    stdin,
    stdout: stdoutOpt,
    stderr: stderrOpt,
    stdio,
    cwd: cwdOpt = ".",
    env: envOpt,
    ...rest
  } = options
  const cwd = cwdOpt instanceof URL ? fileURLToPath(cwdOpt) : path.resolve(cwdOpt)
  const env = envOpt ? { ...process.env, ...envOpt } : undefined
  const resolvedStdio = stdio ?? [stdin, stdoutOpt, stderrOpt]

  const command = [file, ...args]
    .map((part) => getCommandPart(stripVTControlCharacters(part)))
    .join(" ")

  if (["node", "node.exe"].includes(file.toLowerCase())) {
    file = process.execPath
    args = [...process.execArgv.filter((flag) => !flag.startsWith("--inspect")), ...args]
  }

  let spawnOpts: SpawnOptions = { ...rest, stdio: resolvedStdio, env, cwd }

  if (await shouldForceShell(file, spawnOpts)) {
    args = args.map((arg) => escapeArgument(arg))
    file = escapeFile(file)
    spawnOpts = { ...spawnOpts, shell: true }
  }

  if (spawnOpts.shell && args.length > 0) {
    file = [file, ...args].join(" ")
    args = []
  }

  const instance = nodeSpawn(file, args, spawnOpts)

  let stdoutData = ""
  let stderrData = ""
  if (instance.stdout) {
    instance.stdout.setEncoding("utf8")
    instance.stdout.on("data", (chunk: string) => (stdoutData += chunk))
  }
  if (instance.stderr) {
    instance.stderr.setEncoding("utf8")
    instance.stderr.on("data", (chunk: string) => (stderrData += chunk))
  }

  instance.once("error", () => {})

  try {
    await once(instance, "spawn")
  } catch (error) {
    throw Object.assign(new SubprocessError(`Command failed: ${command}`, { cause: error }), {
      stdout: stdoutData,
      stderr: stderrData,
    })
  }

  await once(instance, "close")

  const trimOutput = (s: string) =>
    s.at(-1) === "\n" ? s.slice(0, s.at(-2) === "\r" ? -2 : -1) : s

  if (instance.exitCode && instance.exitCode > 0) {
    throw Object.assign(
      new SubprocessError(`Command failed with exit code ${instance.exitCode}: ${command}`),
      {
        stdout: trimOutput(stdoutData),
        stderr: trimOutput(stderrData),
        exitCode: instance.exitCode,
      },
    )
  }

  if (instance.signalCode) {
    throw Object.assign(
      new SubprocessError(`Command was terminated with ${instance.signalCode}: ${command}`),
      { stdout: trimOutput(stdoutData), stderr: trimOutput(stderrData) },
    )
  }

  return { stdout: trimOutput(stdoutData), stderr: trimOutput(stderrData) }
}


================================================
FILE: bin/external/speed-highlight.ts
================================================
var te = Object.defineProperty
var d = (n) => (t) => {
  var p = n[t]
  if (p) return p()
  throw new Error("Module not found in bundle: " + t)
}
var e = (n, t) => () => (n && (t = n((n = 0))), t)
var a = (n, t) => {
  for (var p in t) te(n, p, { get: t[p], enumerable: !0 })
}
var B = {}
a(B, { default: () => ee })
var ee,
  G = e(() => {
    ee = [
      { type: "cmnt", match: /(;|#).*/gm },
      { expand: "str" },
      { expand: "num" },
      { type: "num", match: /\$[\da-fA-F]*\b/g },
      { type: "kwd", match: /^[a-z]+\s+[a-z.]+\b/gm, sub: [{ type: "func", match: /^[a-z]+/g }] },
      { type: "kwd", match: /^\t*[a-z][a-z\d]*\b/gm },
      { match: /%|\$/g, type: "oper" },
    ]
  })
var H = {}
a(H, { default: () => I })
var k,
  I,
  N = e(() => {
    ;((k = { type: "var", match: /\$\w+|\${[^}]*}|\$\([^)]*\)/g }),
      (I = [
        { sub: "todo", match: /#.*/g },
        { type: "str", match: /(["'])((?!\1)[^\r\n\\]|\\[^])*\1?/g, sub: [k] },
        { type: "oper", match: /(?<=\s|^)\.*\/[a-z/_.-]+/gi },
        {
          type: "kwd",
          match:
            /\s-[a-zA-Z]+|$<|[&|;]+|\b(unset|readonly|shift|export|if|fi|else|elif|while|do|done|for|until|case|esac|break|continue|exit|return|trap|wait|eval|exec|then|declare|enable|local|select|typeset|time|add|remove|install|update|delete)(?=\s|$)/g,
        },
        { expand: "num" },
        { type: "func", match: /(?<=(^|\||\&\&|\;)\s*)[a-z_.-]+(?=\s|$)/gim },
        { type: "bool", match: /(?<=\s|^)(true|false)(?=\s|$)/g },
        { type: "oper", match: /[=(){}<>!]+/g },
        { type: "var", match: /(?<=\s|^)[\w_]+(?=\s*=)/g },
        k,
      ]))
  })
var _ = {}
a(_, { default: () => ae })
var ae,
  z = e(() => {
    ae = [
      { match: /[^\[\->+.<\]\s].*/g, sub: "todo" },
      { type: "func", match: /\.+/g },
      { type: "kwd", match: /[<>]+/g },
      { type: "oper", match: /[+-]+/g },
    ]
  })
var Y = {}
a(Y, { default: () => ne })
var ne,
  Z = e(() => {
    ne = [
      { match: /\/\/.*\n?|\/\*((?!\*\/)[^])*(\*\/)?/g, sub: "todo" },
      { expand: "str" },
      { expand: "num" },
      { type: "kwd", match: /#\s*include (<.*>|".*")/g, sub: [{ type: "str", match: /(<|").*/g }] },
      {
        match: /asm\s*{[^}]*}/g,
        sub: [
          { type: "kwd", match: /^asm/g },
          { match: /[^{}]*(?=}$)/g, sub: "asm" },
        ],
      },
      {
        type: "kwd",
        match:
          /\*|&|#[a-z]+\b|\b(asm|auto|double|int|struct|break|else|long|switch|case|enum|register|typedef|char|extern|return|union|const|float|short|unsigned|continue|for|signed|void|default|goto|sizeof|volatile|do|if|static|while)\b/g,
      },
      { type: "oper", match: /[/*+:?&|%^~=!,<>.^-]+/g },
      { type: "func", match: /[a-zA-Z_][\w_]*(?=\s*\()/g },
      { type: "class", match: /\b[A-Z][\w_]*\b/g },
    ]
  })
var X = {}
a(X, { default: () => se })
var se,
  W = e(() => {
    se = [
      { match: /\/\*((?!\*\/)[^])*(\*\/)?/g, sub: "todo" },
      { expand: "str" },
      { type: "kwd", match: /@\w+\b|\b(and|not|only|or)\b|\b[a-z-]+(?=[^{}]*{)/g },
      { type: "var", match: /\b[\w-]+(?=\s*:)|(::?|\.)[\w-]+(?=[^{}]*{)/g },
      { type: "func", match: /#[\w-]+(?=[^{}]*{)/g },
      { type: "num", match: /#[\da-f]{3,8}/g },
      {
        type: "num",
        match: /\d+(\.\d+)?(cm|mm|in|px|pt|pc|em|ex|ch|rem|vm|vh|vmin|vmax|%)?/g,
        sub: [{ type: "var", match: /[a-z]+|%/g }],
      },
      {
        match: /url\([^)]*\)/g,
        sub: [
          { type: "func", match: /url(?=\()/g },
          { type: "str", match: /[^()]+/g },
        ],
      },
      { type: "func", match: /\b[a-zA-Z]\w*(?=\s*\()/g },
      { type: "num", match: /\b[a-z-]+\b/g },
    ]
  })
var j = {}
a(j, { default: () => pe })
var pe,
  K = e(() => {
    pe = [{ expand: "strDouble" }, { type: "oper", match: /,/g }]
  })
var V = {}
a(V, { default: () => A })
var A,
  R = e(() => {
    A = [
      { type: "deleted", match: /^[-<].*/gm },
      { type: "insert", match: /^[+>].*/gm },
      { type: "kwd", match: /!.*/gm },
      { type: "section", match: /^@@.*@@$|^\d.*|^([*-+])\1\1.*/gm },
    ]
  })
var q = {}
a(q, { default: () => re })
var re,
  Q = e(() => {
    N()
    re = [
      {
        type: "kwd",
        match:
          /^(FROM|RUN|CMD|LABEL|MAINTAINER|EXPOSE|ENV|ADD|COPY|ENTRYPOINT|VOLUME|USER|WORKDIR|ARG|ONBUILD|STOPSIGNAL|HEALTHCHECK|SHELL)\b/gim,
      },
      ...I,
    ]
  })
var J = {}
a(J, { default: () => ce })
var ce,
  tt = e(() => {
    R()
    ce = [
      { match: /^#.*/gm, sub: "todo" },
      { expand: "str" },
      ...A,
      { type: "func", match: /^(\$ )?git(\s.*)?$/gm },
      { type: "kwd", match: /^commit \w+$/gm },
    ]
  })
var et = {}
a(et, { default: () => me })
var me,
  at = e(() => {
    me = [
      { match: /\/\/.*\n?|\/\*((?!\*\/)[^])*(\*\/)?/g, sub: "todo" },
      { expand: "str" },
      { expand: "num" },
      {
        type: "kwd",
        match:
          /\*|&|\b(break|case|chan|const|continue|default|defer|else|fallthrough|for|func|go|goto|if|import|interface|map|package|range|return|select|struct|switch|type|var)\b/g,
      },
      { type: "func", match: /[a-zA-Z_][\w_]*(?=\s*\()/g },
      { type: "class", match: /\b[A-Z][\w_]*\b/g },
      { type: "oper", match: /[+\-*\/%&|^~=!<>.^-]+/g },
    ]
  })
var st = {}
a(st, { default: () => O, name: () => u, properties: () => i, xmlElement: () => l })
var nt,
  oe,
  u,
  i,
  l,
  O,
  x = e(() => {
    ;((nt =
      ":A-Z_a-z\xC0-\xD6\xD8-\xF6\xF8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD"),
      (oe = nt + "\\-\\.0-9\xB7\u0300-\u036F\u203F-\u2040"),
      (u = `[${nt}][${oe}]*`),
      (i = `\\s*(\\s+${u}\\s*(=\\s*([^"']\\S*|("|')(\\\\[^]|(?!\\4)[^])*\\4?)?)?\\s*)*`),
      (l = {
        match: RegExp(`<[/!?]?${u}${i}[/!?]?>`, "g"),
        sub: [
          {
            type: "var",
            match: RegExp(`^<[/!?]?${u}`, "g"),
            sub: [{ type: "oper", match: /^<[\/!?]?/g }],
          },
          {
            type: "str",
            match: /=\s*([^"']\S*|("|')(\\[^]|(?!\2)[^])*\2?)/g,
            sub: [{ type: "oper", match: /^=/g }],
          },
          { type: "oper", match: /[\/!?]?>/g },
          { type: "class", match: RegExp(u, "g") },
        ],
      }),
      (O = [
        { match: /<!--((?!-->)[^])*-->/g, sub: "todo" },
        { type: "class", match: /<!\[CDATA\[[\s\S]*?\]\]>/gi },
        l,
        {
          type: "str",
          match: RegExp(`<\\?${u}([^?]|\\?[^?>])*\\?+>`, "g"),
          sub: [
            {
              type: "var",
              match: RegExp(`^<\\?${u}`, "g"),
              sub: [{ type: "oper", match: /^<\?/g }],
            },
            { type: "oper", match: /\?+>$/g },
          ],
        },
        { type: "var", match: /&(#x?)?[\da-z]{1,8};/gi },
      ]))
  })
var pt = {}
a(pt, { default: () => le })
var le,
  rt = e(() => {
    x()
    le = [
      {
        type: "class",
        match: /<!DOCTYPE("[^"]*"|'[^']*'|[^"'>])*>/gi,
        sub: [
          { type: "str", match: /"[^"]*"|'[^']*'/g },
          { type: "oper", match: /^<!|>$/g },
          { type: "var", match: /DOCTYPE/gi },
        ],
      },
      {
        match: RegExp(`<style${i}>((?!</style>)[^])*</style\\s*>`, "g"),
        sub: [
          { match: RegExp(`^<style${i}>`, "g"), sub: l.sub },
          { match: RegExp(`${l.match}|[^]*(?=</style\\s*>$)`, "g"), sub: "css" },
          l,
        ],
      },
      {
        match: RegExp(`<script${i}>((?!<\/script>)[^])*<\/script\\s*>`, "g"),
        sub: [
          { match: RegExp(`^<script${i}>`, "g"), sub: l.sub },
          { match: RegExp(`${l.match}|[^]*(?=<\/script\\s*>$)`, "g"), sub: "js" },
          l,
        ],
      },
      ...O,
    ]
  })
var ue,
  E,
  b = e(() => {
    ;((ue = [
      ["bash", [/#!(\/usr)?\/bin\/bash/g, 500], [/\b(if|elif|then|fi|echo)\b|\$/g, 10]],
      ["html", [/<\/?[a-z-]+[^\n>]*>/g, 10], [/^\s+<!DOCTYPE\s+html/g, 500]],
      ["http", [/^(GET|HEAD|POST|PUT|DELETE|PATCH|HTTP)\b/g, 500]],
      [
        "js",
        [
          /\b(console|await|async|function|export|import|this|class|for|let|const|map|join|require|document|window)\b/g,
          10,
        ],
      ],
      [
        "ts",
        [
          /\b(console|await|async|function|export|import|this|class|for|let|const|map|join|require|document|window|implements|interface|namespace)\b/g,
          10,
        ],
      ],
      [
        "py",
        [
          /\b(def|print|await|async|class|and|or|lambda|import|from|self|asyncio|pass|True|False|None|__init__)\b/g,
          10,
        ],
      ],
      ["sql", [/\b(SELECT|INSERT|FROM)\b/g, 50]],
      ["pl", [/#!(\/usr)?\/bin\/perl/g, 500], [/\b(use|print)\b|\$/g, 10]],
      ["lua", [/#!(\/usr)?\/bin\/lua/g, 500]],
      ["make", [/\b(ifneq|endif|if|elif|then|fi|echo|.PHONY|^[a-z]+ ?:$)\b|\$/gm, 10]],
      ["uri", [/https?:|mailto:|tel:|ftp:/g, 30]],
      ["css", [/^(@import|@page|@media|(\.|#)[a-z]+)/gm, 20]],
      ["diff", [/^[+><-]/gm, 10], [/^@@ ?[-+,0-9 ]+ ?@@/gm, 25]],
      ["md", [/^(>|\t\*|\t\d+.)/gm, 10], [/\[.*\](.*)/g, 10]],
      ["docker", [/^(FROM|ENTRYPOINT|RUN)/gm, 500]],
      ["xml", [/<\/?[a-z-]+[^\n>]*>/g, 10], [/^<\?xml/g, 500]],
      ["c", [/#include\b|\bprintf\s+\(/g, 100]],
      ["rs", [/^\s+(use|fn|mut|match)\b/gm, 100]],
      ["go", [/\b(func|fmt|package)\b/g, 100]],
      ["java", [/^import\s+java/gm, 500]],
      ["asm", [/^(section|global main|extern|\t(call|mov|ret))/gm, 100]],
      ["css", [/^(@import|@page|@media|(\.|#)[a-z]+)/gm, 20]],
      ["json", [/\b(true|false|null|\{})\b|\"[^"]+\":/g, 10]],
      ["yaml", [/^(\s+)?[a-z][a-z0-9]*:/gim, 10]],
    ]),
      (E = (n) =>
        ue
          .map(([t, ...p]) => [t, p.reduce((r, [m, c]) => r + [...n.matchAll(m)].length * c, 0)])
          .filter(([t, p]) => p > 20)
          .sort((t, p) => p[1] - t[1])[0]?.[0] || "plain"))
  })
var ct = {}
a(ct, { default: () => ie })
var ie,
  mt = e(() => {
    b()
    ie = [
      {
        type: "kwd",
        match: /^(GET|HEAD|POST|PUT|DELETE|CONNECT|OPTIONS|TRACE|PATCH|PRI|SEARCH)\b/gm,
      },
      { expand: "str" },
      { type: "section", match: /\bHTTP\/[\d.]+\b/g },
      { expand: "num" },
      { type: "oper", match: /[,;:=]/g },
      { type: "var", match: /[a-zA-Z][\w-]*(?=:)/g },
      { match: /\n\n[^]*/g, sub: E },
    ]
  })
var ot = {}
a(ot, { default: () => Ee })
var Ee,
  lt = e(() => {
    Ee = [
      { match: /(^[ \f\t\v]*)[#;].*/gm, sub: "todo" },
      { type: "var", match: /.*(?==)/g },
      { type: "section", match: /^\s*\[.+\]\s*$/gm },
      { type: "oper", match: /=/g },
      { type: "str", match: /.*/g },
    ]
  })
var ut = {}
a(ut, { default: () => he })
var he,
  it = e(() => {
    he = [
      { match: /\/\/.*\n?|\/\*((?!\*\/)[^])*(\*\/)?/g, sub: "todo" },
      { expand: "str" },
      { expand: "num" },
      {
        type: "kwd",
        match:
          /\b(abstract|assert|boolean|break|byte|case|catch|char|class|continue|const|default|do|double|else|enum|exports|extends|final|finally|float|for|goto|if|implements|import|instanceof|int|interface|long|module|native|new|package|private|protected|public|requires|return|short|static|strictfp|super|switch|synchronized|this|throw|throws|transient|try|var|void|volatile|while)\b/g,
      },
      { type: "oper", match: /[/*+:?&|%^~=!,<>.^-]+/g },
      { type: "func", match: /[a-zA-Z_][\w_]*(?=\s*\()/g },
      { type: "class", match: /\b[A-Z][\w_]*\b/g },
    ]
  })
var Et = {}
a(Et, { default: () => L })
var L,
  S = e(() => {
    L = [
      { match: /\/\*\*((?!\*\/)[^])*(\*\/)?/g, sub: "jsdoc" },
      { match: /\/\/.*\n?|\/\*((?!\*\/)[^])*(\*\/)?/g, sub: "todo" },
      { expand: "str" },
      { match: /`((?!`)[^]|\\[^])*`?/g, sub: "js_template_literals" },
      {
        type: "kwd",
        match:
          /=>|\b(this|set|get|as|async|await|break|case|catch|class|const|constructor|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|if|implements|import|in|instanceof|interface|let|var|of|new|package|private|protected|public|return|static|super|switch|throw|throws|try|typeof|void|while|with|yield)\b/g,
      },
      { match: /\/((?!\/)[^\r\n\\]|\\.)+\/[dgimsuy]*/g, sub: "regex" },
      { expand: "num" },
      { type: "num", match: /\b(NaN|null|undefined|[A-Z][A-Z_]*)\b/g },
      { type: "bool", match: /\b(true|false)\b/g },
      { type: "oper", match: /[/*+:?&|%^~=!,<>.^-]+/g },
      { type: "class", match: /\b[A-Z][\w_]*\b/g },
      {
        type: "func",
        match: /[a-zA-Z$_][\w$_]*(?=\s*((\?\.)?\s*\(|=\s*(\(?[\w,{}\[\])]+\)? =>|function\b)))/g,
      },
    ]
  })
var ht = {}
a(ht, { default: () => ge, type: () => de })
var ge,
  de,
  gt = e(() => {
    ;((ge = [
      {
        match: new (class {
          exec(n) {
            let t = this.lastIndex,
              p,
              r = (m) => {
                for (; ++t < n.length - 2; )
                  if (n[t] == "{") r()
                  else if (n[t] == "}") return
              }
            for (; t < n.length; ++t)
              if (n[t - 1] != "\\" && n[t] == "$" && n[t + 1] == "{")
                return (
                  (p = t++), r(t), (this.lastIndex = t + 1), { index: p, 0: n.slice(p, t + 1) }
                )
            return null
          }
        })(),
        sub: [
          { type: "kwd", match: /^\${|}$/g },
          { match: /(?!^\$|{)[^]+(?=}$)/g, sub: "js" },
        ],
      },
    ]),
      (de = "str"))
  })
var dt = {}
a(dt, { default: () => C, type: () => be })
var C,
  be,
  w = e(() => {
    ;((C = [
      { type: "err", match: /\b(TODO|FIXME|DEBUG|OPTIMIZE|WARNING|XXX|BUG)\b/g },
      { type: "class", match: /\bIDEA\b/g },
      { type: "insert", match: /\b(CHANGED|FIX|CHANGE)\b/g },
      { type: "oper", match: /\bQUESTION\b/g },
    ]),
      (be = "cmnt"))
  })
var bt = {}
a(bt, { default: () => ye, type: () => Te })
var ye,
  Te,
  yt = e(() => {
    w()
    ;((ye = [
      { type: "kwd", match: /@\w+/g },
      { type: "class", match: /{[\w\s|<>,.@\[\]]+}/g },
      { type: "var", match: /\[[\w\s="']+\]/g },
      ...C,
    ]),
      (Te = "cmnt"))
  })
var Tt = {}
a(Tt, { default: () => fe })
var fe,
  ft = e(() => {
    fe = [
      { type: "var", match: /(("|')((?!\2)[^\r\n\\]|\\[^])*\2|[a-zA-Z]\w*)(?=\s*:)/g },
      { expand: "str" },
      { expand: "num" },
      { type: "num", match: /\bnull\b/g },
      { type: "bool", match: /\b(true|false)\b/g },
    ]
  })
var It = {}
a(It, { default: () => D })
var D,
  U = e(() => {
    b()
    D = [
      { type: "cmnt", match: /^>.*|(=|-)\1+/gm },
      { type: "class", match: /\*\*((?!\*\*).)*\*\*/g },
      {
        match: /```((?!```)[^])*\n```/g,
        sub: (n) => ({
          type: "kwd",
          sub: [
            {
              match: /\n[^]*(?=```)/g,
              sub:
                n
                  .split(`
`)[0]
                  .slice(3) || E(n),
            },
          ],
        }),
      },
      { type: "str", match: /`[^`]*`/g },
      { type: "var", match: /~~((?!~~).)*~~/g },
      { type: "kwd", match: /\b_\S([^\n]*?\S)?_\b|\*\S([^\n]*?\S)?\*/g },
      { type: "kwd", match: /^\s*(\*|\d+\.)\s/gm },
      {
        type: "func",
        match: /\[[^\]]*]\([^)]*\)|<[^>]*>/g,
        sub: [{ type: "oper", match: /^\[[^\]]*]/g }],
      },
    ]
  })
var Nt = {}
a(Nt, { default: () => Ie })
var Ie,
  At = e(() => {
    U()
    b()
    Ie = [
      {
        type: "insert",
        match: /(leanpub-start-insert)((?!leanpub-end-insert)[^])*(leanpub-end-insert)?/g,
        sub: [
          { type: "insert", match: /leanpub-(start|end)-insert/g },
          { match: /(?!leanpub-start-insert)((?!leanpub-end-insert)[^])*/g, sub: E },
        ],
      },
      {
        type: "deleted",
        match: /(leanpub-start-delete)((?!leanpub-end-delete)[^])*(leanpub-end-delete)?/g,
        sub: [
          { type: "deleted", match: /leanpub-(start|end)-delete/g },
          { match: /(?!leanpub-start-delete)((?!leanpub-end-delete)[^])*/g, sub: E },
        ],
      },
      ...D,
    ]
  })
var Rt = {}
a(Rt, { default: () => Ne })
var Ne,
  Ot = e(() => {
    Ne = [
      { type: "cmnt", match: /^#.*/gm },
      { expand: "strDouble" },
      { expand: "num" },
      {
        type: "err",
        match:
          /\b(err(or)?|[a-z_-]*exception|warn|warning|failed|ko|invalid|not ?found|alert|fatal)\b/gi,
      },
      { type: "num", match: /\b(null|undefined)\b/gi },
      { type: "bool", match: /\b(false|true|yes|no)\b/gi },
      { type: "oper", match: /\.|,/g },
    ]
  })
var xt = {}
a(xt, { default: () => Ae })
var Ae,
  Lt = e(() => {
    Ae = [
      { match: /^#!.*|--(\[(=*)\[((?!--\]\2\])[^])*--\]\2\]|.*)/g, sub: "todo" },
      { expand: "str" },
      {
        type: "kwd",
        match:
          /\b(and|break|do|else|elseif|end|for|function|if|in|local|not|or|repeat|return|then|until|while)\b/g,
      },
      { type: "bool", match: /\b(true|false|nil)\b/g },
      { type: "oper", match: /[+*/%^#=~<>:,.-]+/g },
      { expand: "num" },
      { type: "func", match: /[a-z_]+(?=\s*[({])/g },
    ]
  })
var St = {}
a(St, { default: () => Re })
var Re,
  Ct = e(() => {
    Re = [
      { match: /^\s*#.*/gm, sub: "todo" },
      { expand: "str" },
      { type: "oper", match: /[${}()]+/g },
      { type: "class", match: /.PHONY:/gm },
      { type: "section", match: /^[\w.]+:/gm },
      { type: "kwd", match: /\b(ifneq|endif)\b/g },
      { expand: "num" },
      { type: "var", match: /[A-Z_]+(?=\s*=)/g },
      { match: /^.*$/gm, sub: "bash" },
    ]
  })
var wt = {}
a(wt, { default: () => Oe })
var Oe,
  Dt = e(() => {
    Oe = [
      { match: /#.*/g, sub: "todo" },
      { type: "str", match: /(["'])(\\[^]|(?!\1)[^])*\1?/g },
      { expand: "num" },
      {
        type: "kwd",
        match:
          /\b(any|break|continue|default|delete|die|do|else|elsif|eval|for|foreach|given|goto|if|last|local|my|next|our|package|print|redo|require|return|say|state|sub|switch|undef|unless|until|use|when|while|not|and|or|xor)\b/g,
      },
      { type: "oper", match: /[-+*/%~!&<>|=?,]+/g },
      { type: "func", match: /[a-z_]+(?=\s*\()/g },
    ]
  })
var Ut = {}
a(Ut, { default: () => xe })
var xe,
  Pt = e(() => {
    xe = [{ expand: "strDouble" }]
  })
var Ft = {}
a(Ft, { default: () => Le })
var Le,
  Mt = e(() => {
    Le = [
      { match: /#.*/g, sub: "todo" },
      {
        type: "str",
        match: /f("""|''')(\\[^]|(?!\1)[^])*\1?|f("|')(\\[^]|(?!\3).)*\3?/gi,
        sub: [
          { type: "var", match: /{[^{}]*}/g, sub: [{ match: /(?!^{)[^]*(?=}$)/g, sub: "py" }] },
        ],
      },
      { match: /("""|''')(\\[^]|(?!\1)[^])*\1?/g, sub: "todo" },
      { expand: "str" },
      {
        type: "kwd",
        match:
          /\b(and|as|assert|break|class|continue|def|del|elif|else|except|finally|for|from|global|if|import|in|is|lambda|nonlocal|not|or|pass|raise|return|try|while|with|yield)\b/g,
      },
      { type: "bool", match: /\b(False|True|None)\b/g },
      { expand: "num" },
      { type: "func", match: /[a-z_]\w*(?=\s*\()/gi },
      { type: "oper", match: /[-/*+<>,=!&|^%]+/g },
      { type: "class", match: /\b[A-Z][\w_]*\b/g },
    ]
  })
var $t = {}
a($t, { default: () => Se, type: () => Ce })
var Se,
  Ce,
  vt = e(() => {
    ;((Se = [
      { match: /^(?!\/).*/gm, sub: "todo" },
      { type: "num", match: /\[((?!\])[^\\]|\\.)*\]/g },
      { type: "kwd", match: /\||\^|\$|\\.|\w+($|\r|\n)/g },
      { type: "var", match: /\*|\+|\{\d+,\d+\}/g },
    ]),
      (Ce = "oper"))
  })
var Bt = {}
a(Bt, { default: () => we })
var we,
  Gt = e(() => {
    we = [
      { match: /\/\/.*\n?|\/\*((?!\*\/)[^])*(\*\/)?/g, sub: "todo" },
      { expand: "str" },
      { expand: "num" },
      {
        type: "kwd",
        match:
          /\b(as|break|const|continue|crate|else|enum|extern|false|fn|for|if|impl|in|let|loop|match|mod|move|mut|pub|ref|return|self|Self|static|struct|super|trait|true|type|unsafe|use|where|while|async|await|dyn|abstract|become|box|do|final|macro|override|priv|typeof|unsized|virtual|yield|try)\b/g,
      },
      { type: "oper", match: /[/*+:?&|%^~=!,<>.^-]+/g },
      { type: "class", match: /\b[A-Z][\w_]*\b/g },
      { type: "func", match: /[a-zA-Z_][\w_]*(?=\s*!?\s*\()/g },
    ]
  })
var kt = {}
a(kt, { default: () => De })
var De,
  Ht = e(() => {
    De = [
      { match: /--.*\n?|\/\*((?!\*\/)[^])*(\*\/)?/g, sub: "todo" },
      { expand: "str" },
      {
        type: "func",
        match:
          /\b(AVG|COUNT|FIRST|FORMAT|LAST|LCASE|LEN|MAX|MID|MIN|MOD|NOW|ROUND|SUM|UCASE)(?=\s*\()/g,
      },
      {
        type: "kwd",
        match:
          /\b(ACTION|ADD|AFTER|ALGORITHM|ALL|ALTER|ANALYZE|ANY|APPLY|AS|ASC|AUTHORIZATION|AUTO_INCREMENT|BACKUP|BDB|BEGIN|BERKELEYDB|BIGINT|BINARY|BIT|BLOB|BOOL|BOOLEAN|BREAK|BROWSE|BTREE|BULK|BY|CALL|CASCADED?|CASE|CHAIN|CHAR(?:ACTER|SET)?|CHECK(?:POINT)?|CLOSE|CLUSTERED|COALESCE|COLLATE|COLUMNS?|COMMENT|COMMIT(?:TED)?|COMPUTE|CONNECT|CONSISTENT|CONSTRAINT|CONTAINS(?:TABLE)?|CONTINUE|CONVERT|CREATE|CROSS|CURRENT(?:_DATE|_TIME|_TIMESTAMP|_USER)?|CURSOR|CYCLE|DATA(?:BASES?)?|DATE(?:TIME)?|DAY|DBCC|DEALLOCATE|DEC|DECIMAL|DECLARE|DEFAULT|DEFINER|DELAYED|DELETE|DELIMITERS?|DENY|DESC|DESCRIBE|DETERMINISTIC|DISABLE|DISCARD|DISK|DISTINCT|DISTINCTROW|DISTRIBUTED|DO|DOUBLE|DROP|DUMMY|DUMP(?:FILE)?|DUPLICATE|ELSE(?:IF)?|ENABLE|ENCLOSED|END|ENGINE|ENUM|ERRLVL|ERRORS|ESCAPED?|EXCEPT|EXEC(?:UTE)?|EXISTS|EXIT|EXPLAIN|EXTENDED|FETCH|FIELDS|FILE|FILLFACTOR|FIRST|FIXED|FLOAT|FOLLOWING|FOR(?: EACH ROW)?|FORCE|FOREIGN|FREETEXT(?:TABLE)?|FROM|FULL|FUNCTION|GEOMETRY(?:COLLECTION)?|GLOBAL|GOTO|GRANT|GROUP|HANDLER|HASH|HAVING|HOLDLOCK|HOUR|IDENTITY(?:_INSERT|COL)?|IF|IGNORE|IMPORT|INDEX|INFILE|INNER|INNODB|INOUT|INSERT|INT|INTEGER|INTERSECT|INTERVAL|INTO|INVOKER|ISOLATION|ITERATE|JOIN|kwdS?|KILL|LANGUAGE|LAST|LEAVE|LEFT|LEVEL|LIMIT|LINENO|LINES|LINESTRING|LOAD|LOCAL|LOCK|LONG(?:BLOB|TEXT)|LOOP|MATCH(?:ED)?|MEDIUM(?:BLOB|INT|TEXT)|MERGE|MIDDLEINT|MINUTE|MODE|MODIFIES|MODIFY|MONTH|MULTI(?:LINESTRING|POINT|POLYGON)|NATIONAL|NATURAL|NCHAR|NEXT|NO|NONCLUSTERED|NULLIF|NUMERIC|OFF?|OFFSETS?|ON|OPEN(?:DATASOURCE|QUERY|ROWSET)?|OPTIMIZE|OPTION(?:ALLY)?|ORDER|OUT(?:ER|FILE)?|OVER|PARTIAL|PARTITION|PERCENT|PIVOT|PLAN|POINT|POLYGON|PRECEDING|PRECISION|PREPARE|PREV|PRIMARY|PRINT|PRIVILEGES|PROC(?:EDURE)?|PUBLIC|PURGE|QUICK|RAISERROR|READS?|REAL|RECONFIGURE|REFERENCES|RELEASE|RENAME|REPEAT(?:ABLE)?|REPLACE|REPLICATION|REQUIRE|RESIGNAL|RESTORE|RESTRICT|RETURN(?:S|ING)?|REVOKE|RIGHT|ROLLBACK|ROUTINE|ROW(?:COUNT|GUIDCOL|S)?|RTREE|RULE|SAVE(?:POINT)?|SCHEMA|SECOND|SELECT|SERIAL(?:IZABLE)?|SESSION(?:_USER)?|SET(?:USER)?|SHARE|SHOW|SHUTDOWN|SIMPLE|SMALLINT|SNAPSHOT|SOME|SONAME|SQL|START(?:ING)?|STATISTICS|STATUS|STRIPED|SYSTEM_USER|TABLES?|TABLESPACE|TEMP(?:ORARY|TABLE)?|TERMINATED|TEXT(?:SIZE)?|THEN|TIME(?:STAMP)?|TINY(?:BLOB|INT|TEXT)|TOP?|TRAN(?:SACTIONS?)?|TRIGGER|TRUNCATE|TSEQUAL|TYPES?|UNBOUNDED|UNCOMMITTED|UNDEFINED|UNION|UNIQUE|UNLOCK|UNPIVOT|UNSIGNED|UPDATE(?:TEXT)?|USAGE|USE|USER|USING|VALUES?|VAR(?:BINARY|CHAR|CHARACTER|YING)|VIEW|WAITFOR|WARNINGS|WHEN|WHERE|WHILE|WITH(?: ROLLUP|IN)?|WORK|WRITE(?:TEXT)?|YEAR)\b/g,
      },
      { type: "num", match: /\.?\d[\d.oxa-fA-F-]*|\bNULL\b/g },
      { type: "bool", match: /\b(TRUE|FALSE)\b/g },
      {
        type: "oper",
        match:
          /[-+*\/=%^~]|&&?|\|\|?|!=?|<(?:=>?|<|>)?|>[>=]?|\b(?:AND|BETWEEN|DIV|IN|ILIKE|IS|LIKE|NOT|OR|REGEXP|RLIKE|SOUNDS LIKE|XOR)\b/g,
      },
      { type: "var", match: /@\S+/g },
    ]
  })
var _t = {}
a(_t, { default: () => Ue })
var Ue,
  zt = e(() => {
    Ue = [
      { match: /#.*/g, sub: "todo" },
      { type: "str", match: /("""|''')((?!\1)[^]|\\[^])*\1?/g },
      { expand: "str" },
      { type: "section", match: /^\[.+\]\s*$/gm },
      { type: "num", match: /\b(inf|nan)\b|\d[\d:ZT.-]*/g },
      { expand: "num" },
      { type: "bool", match: /\b(true|false)\b/g },
      { type: "oper", match: /[+,.=-]/g },
      { type: "var", match: /\w+(?= \=)/g },
    ]
  })
var Yt = {}
a(Yt, { default: () => Pe })
var Pe,
  Zt = e(() => {
    S()
    Pe = [
      { type: "type", match: /:\s*(any|void|number|boolean|string|object|never|enum)\b/g },
      {
        type: "kwd",
        match:
          /\b(type|namespace|typedef|interface|public|private|protected|implements|declare|abstract|readonly)\b/g,
      },
      ...L,
    ]
  })
var Xt = {}
a(Xt, { default: () => Fe })
var Fe,
  Wt = e(() => {
    Fe = [
      { match: /^#.*/gm, sub: "todo" },
      { type: "class", match: /^\w+(?=:?)/gm },
      { type: "num", match: /:\d+/g },
      { type: "oper", match: /[:/&?]|\w+=/g },
      { type: "func", match: /[.\w]+@|#[\w]+$/gm },
      { type: "var", match: /\w+\.\w+(\.\w+)*/g },
    ]
  })
var jt = {}
a(jt, { default: () => Me })
var Me,
  Kt = e(() => {
    Me = [
      { match: /#.*/g, sub: "todo" },
      { expand: "str" },
      { type: "str", match: /(>|\|)\r?\n((\s[^\n]*)?(\r?\n|$))*/g },
      { type: "type", match: /!![a-z]+/g },
      { type: "bool", match: /\b(Yes|No)\b/g },
      { type: "oper", match: /[+:-]/g },
      { expand: "num" },
      { type: "var", match: /[a-zA-Z]\w*(?=:)/g },
    ]
  })
var Vt = {}
a(Vt, { default: () => s })
var s,
  y = e(() => {
    s = {
      black: "\x1B[30m",
      red: "\x1B[31m",
      green: "\x1B[32m",
      gray: "\x1B[90m",
      yellow: "\x1B[33m",
      blue: "\x1B[34m",
      magenta: "\x1B[35m",
      cyan: "\x1B[36m",
      white: "\x1B[37m",
    }
  })
var qt = {}
a(qt, { default: () => ve })
var ve,
  Qt = e(() => {
    y()
    ve = {
      deleted: s.red,
      var: s.red,
      err: s.red,
      kwd: s.magenta,
      num: s.yellow,
      class: s.yellow,
      cmnt: s.gray,
      insert: s.green,
      str: s.green,
      bool: s.cyan,
      type: s.blue,
      oper: s.blue,
      section: s.magenta,
      func: s.blue,
    }
  })
var M = {}
a(M, { default: () => Be })
var Be,
  $ = e(() => {
    y()
    Be = {
      deleted: s.red,
      var: s.red,
      err: s.red,
      kwd: s.red,
      num: s.yellow,
      class: s.yellow,
      cmnt: s.gray,
      insert: s.green,
      str: s.green,
      bool: s.cyan,
      type: s.blue,
      oper: s.blue,
      section: s.magenta,
      func: s.magenta,
    }
  })
var v = {
  num: { type: "num", match: /(\.e?|\b)\d(e-|[\d.oxa-fA-F_])*(\.|\b)/g },
  str: { type: "str", match: /(["'])(\\[^]|(?!\1)[^\r\n\\])*\1?/g },
  strDouble: { type: "str", match: /"((?!")[^\r\n\\]|\\[^])*"?/g },
}
var $e = d({
  "./languages/asm.js": () => Promise.resolve().then(() => (G(), B)),
  "./languages/bash.js": () => Promise.resolve().then(() => (N(), H)),
  "./languages/bf.js": () => Promise.resolve().then(() => (z(), _)),
  "./languages/c.js": () => Promise.resolve().then(() => (Z(), Y)),
  "./languages/css.js": () => Promise.resolve().then(() => (W(), X)),
  "./languages/csv.js": () => Promise.resolve().then(() => (K(), j)),
  "./languages/diff.js": () => Promise.resolve().then(() => (R(), V)),
  "./languages/docker.js": () => Promise.resolve().then(() => (Q(), q)),
  "./languages/git.js": () => Promise.resolve().then(() => (tt(), J)),
  "./languages/go.js": () => Promise.resolve().then(() => (at(), et)),
  "./languages/html.js": () => Promise.resolve().then(() => (rt(), pt)),
  "./languages/http.js": () => Promise.resolve().then(() => (mt(), ct)),
  "./languages/ini.js": () => Promise.resolve().then(() => (lt(), ot)),
  "./languages/java.js": () => Promise.resolve().then(() => (it(), ut)),
  "./languages/js.js": () => Promise.resolve().then(() => (S(), Et)),
  "./languages/js_template_literals.js": () => Promise.resolve().then(() => (gt(), ht)),
  "./languages/jsdoc.js": () => Promise.resolve().then(() => (yt(), bt)),
  "./languages/json.js": () => Promise.resolve().then(() => (ft(), Tt)),
  "./languages/leanpub-md.js": () => Promise.resolve().then(() => (At(), Nt)),
  "./languages/log.js": () => Promise.resolve().then(() => (Ot(), Rt)),
  "./languages/lua.js": () => Promise.resolve().then(() => (Lt(), xt)),
  "./languages/make.js": () => Promise.resolve().then(() => (Ct(), St)),
  "./languages/md.js": () => Promise.resolve().then(() => (U(), It)),
  "./languages/pl.js": () => Promise.resolve().then(() => (Dt(), wt)),
  "./languages/plain.js": () => Promise.resolve().then(() => (Pt(), Ut)),
  "./languages/py.js": () => Promise.resolve().then(() => (Mt(), Ft)),
  "./languages/regex.js": () => Promise.resolve().then(() => (vt(), $t)),
  "./languages/rs.js": () => Promise.resolve().then(() => (Gt(), Bt)),
  "./languages/sql.js": () => Promise.resolve().then(() => (Ht(), kt)),
  "./languages/todo.js": () => Promise.resolve().then(() => (w(), dt)),
  "./languages/toml.js": () => Promise.resolve().then(() => (zt(), _t)),
  "./languages/ts.js": () => Promise.resolve().then(() => (Zt(), Yt)),
  "./languages/uri.js": () => Promise.resolve().then(() => (Wt(), Xt)),
  "./languages/xml.js": () => Promise.resolve().then(() => (x(), st)),
  "./languages/yaml.js": () => Promise.resolve().then(() => (Kt(), jt)),
})
var P = {}
async function F(n, t, p) {
  try {
    let r,
      m,
      c = {},
      T,
      o = [],
      h = 0,
      f = typeof t == "string" ? await (P[t] ?? (P[t] = $e(`./languages/${t}.js`))) : t,
      g = [...(typeof t == "string" ? f.default : t.sub)]
    for (; h < n.length; ) {
      for (c.index = null, r = g.length; r-- > 0; ) {
        if (((m = g[r].expand ? v[g[r].expand] : g[r]), o[r] === void 0 || o[r].match.index < h)) {
          if (((m.match.lastIndex = h), (T = m.match.exec(n)), T === null)) {
            ;(g.splice(r, 1), o.splice(r, 1))
            continue
          }
          o[r] = { match: T, lastIndex: m.match.lastIndex }
        }
        o[r].match[0] &&
          (o[r].match.index <= c.index || c.index === null) &&
          (c = { part: m, index: o[r].match.index, match: o[r].match[0], end: o[r].lastIndex })
      }
      if (c.index === null) break
      ;(p(n.slice(h, c.index), f.type),
        (h = c.end),
        c.part.sub
          ? await F(
              c.match,
              typeof c.part.sub == "string"
                ? c.part.sub
                : typeof c.part.sub == "function"
                  ? c.part.sub(c.match)
                  : c.part,
              p,
            )
          : p(c.match, c.part.type))
    }
    p(n.slice(h, n.length), f.type)
  } catch {
    p(n)
  }
}
var Ge = d({
  "./themes/atom-dark.js": () => Promise.resolve().then(() => (Qt(), qt)),
  "./themes/default.js": () => Promise.resolve().then(() => ($(), M)),
  "./themes/termcolor.js": () => Promise.resolve().then(() => (y(), Vt)),
})
var Jt = Promise.resolve().then(() => ($(), M)),
  ke = async (n, t) => {
    let p = "",
      r = (await Jt).default
    return (await F(n, t, (m, c) => (p += c ? `${r[c] ?? ""}${m}\x1B[0m` : m)), p)
  },
  la = async (n, t) => console.log(await ke(n, t)),
  ua = async (n) => (Jt = Ge(`./themes/${n}.js`))
export { ke as highlightText, la as printHighlight, ua as setTheme }


================================================
FILE: bin/external/strip-json-comments.ts
================================================
// From strip-json-comments by Sindre Sorhus (https://github.com/sindresorhus/strip-json-comments)
const singleComment = Symbol("singleComment")
const multiComment = Symbol("multiComment")

const stripWithWhitespace = (string: string, start: number, end?: number) =>
  string.slice(start, end).replace(/[^ \t\r\n]/g, " ")

const isEscaped = (jsonString: string, quotePosition: number) => {
  let index = quotePosition - 1
  let backslashCount = 0
  while (jsonString[index] === "\\") {
    index -= 1
    backslashCount += 1
  }
  return Boolean(backslashCount % 2)
}

export default function stripJsonComments(jsonString: string) {
  if (typeof jsonString !== "string") {
    throw new TypeError(
      `Expected argument \`jsonString\` to be a \`string\`, got \`${typeof jsonString}\``,
    )
  }

  let isInsideString = false
  let isInsideComment: symbol | false = false
  let offset = 0
  let buffer = ""
  let result = ""
  let commaIndex = -1

  for (let index = 0; index < jsonString.length; index++) {
    const currentCharacter = jsonString[index]
    const nextCharacter = jsonString[index + 1]

    if (!isInsideComment && currentCharacter === '"') {
      const escaped = isEscaped(jsonString, index)
      if (!escaped) {
        isInsideString = !isInsideString
      }
    }

    if (isInsideString) {
      continue
    }

    if (!isInsideComment && currentCharacter + nextCharacter === "//") {
      buffer += jsonString.slice(offset, index)
      offset = index
      isInsideComment = singleComment
      index++
    } else if (isInsideComment === singleComment && currentCharacter + nextCharacter === "\r\n") {
      index++
      isInsideComment = false
      buffer += stripWithWhitespace(jsonString, offset, index)
      offset = index
      continue
    } else if (isInsideComment === singleComment && currentCharacter === "\n") {
      isInsideComment = false
      buffer += stripWithWhitespace(jsonString, offset, index)
      offset = index
    } else if (!isInsideComment && currentCharacter + nextCharacter === "/*") {
      buffer += jsonString.slice(offset, index)
      offset = index
      isInsideComment = multiComment
      index++
      continue
    } else if (isInsideComment === multiComment && currentCharacter + nextCharacter === "*/") {
      index++
      isInsideComment = false
      buffer += stripWithWhitespace(jsonString, offset, index + 1)
      offset = index + 1
      continue
    } else if (!isInsideComment) {
      if (commaIndex !== -1) {
        if (currentCharacter === "}" || currentCharacter === "]") {
          buffer += jsonString.slice(offset, index)
          result += stripWithWhitespace(buffer, 0, 1) + buffer.slice(1)
          buffer = ""
          offset = index
          commaIndex = -1
        } else if (
          currentCharacter !== " " &&
          currentCharacter !== "\t" &&
          currentCharacter !== "\r" &&
          currentCharacter !== "\n"
        ) {
          buffer += jsonString.slice(offset, index)
          offset = index
          commaIndex = -1
        }
      } else if (currentCharacter === ",") {
        result += buffer + jsonString.slice(offset, index)
        buffer = ""
        offset = index
        commaIndex = index
      }
    }
  }

  const remaining =
    isInsideComment === singleComment
      ? stripWithWhitespace(jsonString, offset)
      : jsonString.slice(offset)

  return result + buffer + remaining
}


================================================
FILE: bin/external/yocto-spinner.ts
================================================
// Trimmed from yocto-spinner by Sindre Sorhus (https://github.com/sindresorhus/yocto-spinner)
import process from "node:process"
import { stripVTControlCharacters } from "node:util"

import { cyan, green } from "@/external/yoctocolors"

const isUnicodeSupported =
  process.platform !== "win32" ||
  Boolean(process.env.WT_SESSION) ||
  process.env.TERM_PROGRAM === "vscode"

const isInteractive = (stream: NodeJS.WriteStream) =>
  Boolean(stream.isTTY && process.env.TERM !== "dumb" && !("CI" in process.env))

const successSymbol = green(isUnicodeSupported ? "✔" : "√")

const frames = isUnicodeSupported
  ? ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
  : ["-", "\\", "|", "/"]
const interval = 80

export const spinner = (options: { text?: string; stream?: NodeJS.WriteStream } = {}) => {
  let currentFrame = -1
  let timer: ReturnType<typeof setInterval> | undefined
  let text = options.text ?? ""
  const stream = options.stream ?? process.stderr
  const interactive = isInteractive(stream)
  let lines = 0
  let lastFrameTime = 0
  let spinning = false

  const write = (str: string) => stream.write(str)

  const clear = () => {
    if (!interactive || lines === 0) return
    stream.cursorTo(0)
    for (let i = 0; i < lines; i++) {
      if (i > 0) stream.moveCursor(0, -1)
      stream.clearLine(1)
    }
    lines = 0
  }

  const lineCount = (str: string) => {
    const width = stream.columns ?? 80
    const stripped = stripVTControlCharacters(str).split("\n")
    let count = 0
    for (const line of stripped) {
      count += Math.max(1, Math.ceil(line.length / width))
    }
    return count
  }

  const render = () => {
    if (!interactive) return
    const now = Date.now()
    if (currentFrame === -1 || now - lastFrameTime >= interval) {
      currentFrame = ++currentFrame % frames.length
      lastFrameTime = now
    }
    const frame = frames[currentFrame]
    const string = `${cyan(frame)} ${text}`
    clear()
    write(string)
    lines = lineCount(string)
  }

  return {
    start(t: string) {
      text = t
      spinning = true
      if (interactive) write("\x1B[?25l")
      render()
      if (interactive) {
        timer = setInterval(render, interval)
      }
      return this
    },
    success(t: string) {
      if (!spinning) return this
      spinning = false
      if (timer) {
        clearInterval(timer)
        timer = undefined
      }
      clear()
      if (interactive) write("\x1B[?25h")
      write(`${successSymbol} ${t ?? text}\n`)
      return this
    },
  }
}


================================================
FILE: bin/external/yoctocolors.ts
================================================
// Trimmed from yoctocolors by Sindre Sorhus (https://github.com/sindresorhus/yoctocolors)
import tty from "node:tty"

const hasColors = tty?.WriteStream?.prototype?.hasColors?.() ?? false

const format = (open: number, close: number) => {
  if (!hasColors) {
    return (input: string) => input
  }

  const openCode = `\u001B[${open}m`
  const closeCode = `\u001B[${close}m`

  return (input: string) => {
    const string = input + ""
    let index = string.indexOf(closeCode)

    if (index === -1) {
      return openCode + string + closeCode
    }

    let result = openCode
    let lastIndex = 0

    const reopenOnNestedClose = close === 22
    const replaceCode = (reopenOnNestedClose ? closeCode : "") + openCode

    while (index !== -1) {
      result += string.slice(lastIndex, index) + replaceCode
      lastIndex = index + closeCode.length
      index = string.indexOf(closeCode, lastIndex)
    }

    result += string.slice(lastIndex) + closeCode

    return result
  }
}

export const bold = format(1, 22)
export const dim = format(2, 22)
export const red = format(31, 39)
export const green = format(32, 39)
export const yellow = format(33, 39)
export const cyan = format(36, 39)


================================================
FILE: bin/index.ts
================================================
#!/usr/bin/env node
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { parseArgs } from "node:util"

import spawn from "@/external/nano-spawn"
import { spinner } from "@/external/yocto-spinner"
import { bold, cyan, green, red, yellow } from "@/external/yoctocolors"
import { cloneAction } from "@/utils/clone-action"
import { copyDir } from "@/utils/copy-dir"
import { type TreeEntry, interactivePicker } from "@/utils/interactive-picker"
import { parseTimeString } from "@/utils/parse-time-string"
import { configFromUrl } from "@/utils/transform-url"
import { notifyUpdate, scheduleUpdateCheck } from "@/utils/update-notifier"
import { useConfig } from "@/utils/use-config"
import { name, version } from "~/package.json"

const terminalLink = (text: string, url: string) => `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`

const helpMessage = `
With ${bold(`${terminalLink("GitPick", "https://github.com/nrjdalal/gitpick")}`)} clone specific directories or files from GitHub, GitLab, Bitbucket and Codeberg!

  $ gitpick ${yellow("<url>")} ${green("[target]")} ${cyan("[options]")}

${bold("Hint:")}
  [target] and [options] are optional and if not specified,
  GitPick fallbacks to the default behavior of \`git clone\`

${bold("Arguments:")}
  ${yellow("url")}                GitHub/GitLab/Bitbucket/Codeberg URL with path to file/folder/repository
  ${green("target")}             Directory to clone into (optional)

${bold("Options:")}
  ${cyan("-b, --branch ")}      Branch/SHA to clone
  ${cyan("-i, --interactive")}  Browse and pick files/folders interactively
  ${cyan("-n, --dry-run")}      Show what would be cloned without cloning
  ${cyan("-o, --overwrite")}    Skip overwrite prompt
  ${cyan("-r, --recursive")}    Clone submodules
  ${cyan("-w, --watch [time]")} Watch the repository and sync every [time]
                     (e.g. 1h, 30m, 15s)
  ${cyan("    --tree")}         List copied files as a tree
  ${cyan("-q, --quiet")}        Suppress all output except errors
  ${cyan("    --verbose")}      Show detailed clone information
  ${cyan("-h, --help")}         display help for command
  ${cyan("-v, --version")}      display the version number

${bold("Examples:")}
  $ gitpick <url>
  $ gitpick <url> [target]
  $ gitpick <url> [target] -b [branch/SHA]
  $ gitpick <url> [target] -w [time]
  $ gitpick <url> [target] -b [branch/SHA] -w [time]
  $ gitpick <url> --dry-run
  $ gitpick https://gitlab.com/owner/repo
  $ gitpick https://bitbucket.org/owner/repo
  $ gitpick https://codeberg.org/owner/repo

🚀 More awesome tools at ${cyan("https://github.com/nrjdalal")}`

const displayPath = (targetPath: string) => {
  const cwd = process.cwd()
  const home = os.homedir()
  const sep = path.sep
  if (targetPath === cwd) return "."
  if (targetPath.startsWith(cwd + sep))
    return "./" + path.relative(cwd, targetPath).replaceAll(sep, "/")
  if (targetPath.startsWith(home + sep))
    return "~/" + path.relative(home, targetPath).replaceAll(sep, "/")
  return targetPath
}

const printTree = async (dir: string, prefix = "") => {
  const entries = (await fs.promises.readdir(dir, { withFileTypes: true }))
    .filter((e) => e.name !== ".git")
    .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }))

  for (let i = 0; i < entries.length; i++) {
    const entry = entries[i]
    const last = i === entries.length - 1
    const connector = last ? "└── " : "├── "
    const entryPath = path.join(dir, entry.name)

    if (entry.isSymbolicLink()) {
      const linkTarget = await fs.promises.readlink(entryPath)
      let resolvedIsDir = false
      try {
        resolvedIsDir = fs.statSync(entryPath).isDirectory()
      } catch {}
      process.stdout.write(
        `${prefix}${connector}${yellow(entry.name)} -> ${resolvedIsDir ? cyan(linkTarget) : linkTarget}\n`,
      )
    } else if (entry.isDirectory()) {
      process.stdout.write(`${prefix}${connector}${cyan(entry.name)}\n`)
      await printTree(entryPath, `${prefix}${last ? "    " : "│   "}`)
    } else {
      process.stdout.write(`${prefix}${connector}${entry.name}\n`)
    }
  }
}

const parse: typeof parseArgs = (config) => {
  try {
    return parseArgs(config)
  } catch (err: any) {
    throw new Error(`Error parsing arguments: ${err.message}`)
  }
}

const main = async () => {
  scheduleUpdateCheck()

  try {
    const { positionals, values } = parse({
      allowPositionals: true,
      options: {
        branch: { type: "string", short: "b" },
        "dry-run": { type: "boolean", short: "n" },
        force: { type: "boolean", short: "f" },
        help: { type: "boolean", short: "h" },
        interactive: { type: "boolean", short: "i" },
        quiet: { type: "boolean", short: "q" },
        tree: { type: "boolean" },
        verbose: { type: "boolean" },
        overwrite: { type: "boolean", short: "o" },
        recursive: { type: "boolean", short: "r" },
        version: { type: "boolean", short: "v" },
        watch: { type: "string", short: "w" },
      },
    })

    if (!positionals.length) {
      if (values.version) {
        console.log(`\n${name}@${version}`)
        process.exit(0)
      }

      // `gitpick -i` with no args — browse cwd
      if (values.interactive) {
        positionals.push(".")
      } else {
        if (await useConfig()) process.exit(0)

        console.log(helpMessage)
        process.exit(0)
      }
    }

    if (positionals[0] === "clone") {
      positionals.shift()
    }

    let [url, target] = positionals

    const options = {
      branch: values.branch,
      dryRun: values["dry-run"],
      force: values.force,
      interactive: values.interactive,
      quiet: values.quiet,
      tree: values.tree,
      verbose: values.verbose,
      overwrite: values.overwrite,
      recursive: values.recursive,
      watch: values.watch,
    }

    // Local directory interactive mode — detect local paths or
    // non-URL-like positionals when -i is set (e.g. `gitpick -i target`)
    const isLocalPath =
      url === "." ||
      url.startsWith("./") ||
      url.startsWith("../") ||
      url.startsWith("/") ||
      url.startsWith("~/") ||
      (options.interactive &&
        !url.includes("/") &&
        !url.startsWith("http") &&
        !url.startsWith("git@"))

    if (isLocalPath && options.interactive) {
      // Single positional that doesn't exist — treat as target (e.g. `gitpick -i hello`)
      // Only when no explicit target is given; with two args, a missing source is an error
      if (
        !fs.existsSync(path.resolve(url.startsWith("~/") ? url.replace("~", os.homedir()) : url))
      ) {
        if (target) {
          throw new Error(`Directory not found: ${url}`)
        }
        target = url
        url = "."
      }
      if (!process.stdout.isTTY) {
        throw new Error("Interactive mode requires a TTY")
      }

      const resolvedSource = path.resolve(
        url.startsWith("~/") ? url.replace("~", os.homedir()) : url,
      )

      if (!fs.existsSync(resolvedSource)) {
        throw new Error(`Directory not found: ${url}`)
      }
      if (!fs.statSync(resolvedSource).isDirectory()) {
        throw new Error(`Not a directory: ${url}`)
      }

      const targetDir = target ? path.resolve(target) : null

      const entries: TreeEntry[] = []

      // Try git ls-files first (respects .gitignore)
      let usedGit = false
      try {
        const result = await spawn(
          "git",
          ["ls-files", "--cached", "--others", "--exclude-standard"],
          {
            cwd: resolvedSource,
          },
        )
        const files = result.stdout.trim().split("\n").filter(Boolean)
        for (const file of files) {
          const parts = file.split("/")
          // Add parent directories
          for (let i = 1; i < parts.length; i++) {
            const dirPath = parts.slice(0, i).join("/")
            if (!entries.some((e) => e.path === dirPath)) {
              entries.push({ path: dirPath, type: "tree" })
            }
          }
          const filePath = path.join(resolvedSource, file)
          try {
            const stat = await fs.promises.lstat(filePath)
            if (stat.isSymbolicLink()) {
              const linkTarget = await fs.promises.readlink(filePath)
              let resolvedIsDir = false
              try {
                resolvedIsDir = (await fs.promises.stat(filePath)).isDirectory()
              } catch {}
              entries.push({
                path: file,
                type: "symlink",
                linkTarget: resolvedIsDir ? linkTarget + "/" : linkTarget,
              })
            } else {
              entries.push({ path: file, type: "blob", size: stat.size })
            }
          } catch {}
        }
        usedGit = true
      } catch {}

      // Fallback: walk directory manually (skip .git only)
      if (!usedGit) {
        async function walkLocal(dir: string, rel: string) {
          let items
          try {
            items = await fs.promises.readdir(dir, { withFileTypes: true })
          } catch {
            return
          }
          for (const item of items) {
            if (item.name === ".git") continue
            const itemRel = rel ? `${rel}/${item.name}` : item.name
            const itemPath = path.join(dir, item.name)
            if (item.isSymbolicLink()) {
              const linkTarget = await fs.promises.readlink(itemPath)
              let resolvedIsDir = false
              try {
                resolvedIsDir = (await fs.promises.stat(itemPath)).isDirectory()
              } catch {}
              entries.push({
                path: itemRel,
                type: "symlink",
                linkTarget: resolvedIsDir ? linkTarget + "/" : linkTarget,
              })
            } else if (item.isDirectory()) {
              entries.push({ path: itemRel, type: "tree" })
              await walkLocal(itemPath, itemRel)
            } else {
              try {
                const stat = await fs.promises.stat(itemPath)
                entries.push({ path: itemRel, type: "blob", size: stat.size })
              } catch {}
            }
          }
        }
        await walkLocal(resolvedSource, "")
      }

      if (!entries.length) {
        console.log(yellow("\nDirectory is empty."))
        process.exit(0)
      }

      const selected = await interactivePicker(
        entries,
        `${displayPath(resolvedSource)}`,
        resolvedSource,
      )

      if (!selected.length) {
        console.log("\nNo files selected.")
        process.exit(0)
      }

      if (options.dryRun) {
        console.log(
          `\n${green("✔")} Would pick ${selected.length} path${selected.length !== 1 ? "s" : ""}:`,
        )
        for (const sel of selected) console.log(`  ${sel}`)
        console.log()
        process.exit(0)
      }

      if (!targetDir) {
        // No target - just list selected paths
        console.log(
          `\n${green("✔")} Selected ${selected.length} path${selected.length !== 1 ? "s" : ""}:`,
        )
        for (const sel of selected) console.log(`  ${sel}`)
        console.log()
        process.exit(0)
      }

      const resolvedTarget = path.resolve(targetDir)
      if (resolvedSource === resolvedTarget) {
        throw new Error("Source and target directories are the same")
      }
      if (resolvedTarget.startsWith(resolvedSource + path.sep)) {
        throw new Error("Target directory is inside the source directory")
      }

      console.log(
        `\n${green("✔")} Picking ${selected.length} selected path${selected.length !== 1 ? "s" : ""}...`,
      )

      options.overwrite = options.overwrite || options.force
      if (fs.existsSync(targetDir) && !options.overwrite) {
        if ((await fs.promises.readdir(targetDir)).length) {
          console.log(
            `${yellow(`\nWarning: The target directory exists at ${green(target!)} and is not empty. Use ${cyan("-f")} or ${cyan("-o")} to overwrite.`)}`,
          )
          process.exit(1)
        }
      }

      await fs.promises.mkdir(targetDir, { recursive: true })

      let copiedFiles = 0
      for (const sel of selected) {
        const src = path.join(resolvedSource, sel)
        const dest = path.join(targetDir, sel)
        const lstat = await fs.promises.lstat(src).catch(() => null)
        if (!lstat) continue

        await fs.promises.mkdir(path.dirname(dest), { recursive: true })
        if (lstat.isSymbolicLink()) {
          const linkTarget = await fs.promises.readlink(src)
          try {
            await fs.promises.rm(dest, { force: true })
            await fs.promises.symlink(linkTarget, dest)
            copiedFiles++
          } catch (err: any) {
            console.log(yellow(`  Warning: failed to copy symlink ${sel}: ${err.message}`))
          }
        } else if (lstat.isDirectory()) {
          await fs.promises.mkdir(dest, { recursive: true })
          const files = await copyDir(src, dest)
          copiedFiles += files.length
        } else {
          await fs.promises.copyFile(src, dest)
          copiedFiles++
        }
      }

      console.log(
        green(
          `✔ Copied ${copiedFiles} file${copiedFiles !== 1 ? "s" : ""} to ${displayPath(targetDir)}`,
        ),
      )
      if (options.tree) {
        process.stdout.write(`\n${bold(cyan(displayPath(targetDir)))}\n`)
        await printTree(targetDir)
        process.stdout.write("\n")
      }
      process.exit(0)
    }

    const silent = options.tree || options.quiet

    if (!silent) {
      console.log(
        `\nWith ${bold(`${terminalLink("GitPick", "https://github.com/nrjdalal/gitpick")}`)} clone specific files, folders, branches,\ncommits and much more from GitHub, GitLab, Bitbucket and Codeberg!`,
      )
    }

    const config = await configFromUrl(url, {
      branch: options.branch,
      target,
    })

    if (config.type === "blob") {
      const parts = config.target.split(/[/\\]/).filter((part) => part !== "")
      let lastPart = parts[parts.length - 1]
      if (lastPart !== "." && lastPart !== ".." && lastPart.includes(".")) {
        parts.pop()
      } else {
        lastPart = config.path.split("/").pop() || lastPart
      }
      config.target = [...parts, lastPart].join("/")
    }

    if (!silent) {
      console.info(
        `\n${green("✔")} ${config.owner}/${config.repository} ${cyan(config.type + ":" + config.branch)} ${
          config.type === "repository"
            ? `> ${green(config.target)}`
            : `${!config.path.length ? ">" : yellow(config.path) + " >"} ${green(config.target)}`
        }`,
      )
    }

    const targetPath = path.resolve(config.target)

    if (options.interactive) {
      if (!process.stdout.isTTY) {
        throw new Error("Interactive mode requires a TTY")
      }

      // Shallow clone to temp first
      const tempDir = path.resolve(
        os.tmpdir(),
        `gitpick-interactive-${Date.now()}${Math.random().toString(16).slice(2, 6)}`,
      )
      const repoUrl = `https://${config.token ? config.token + "@" : ""}${config.host}/${config.owner}/${config.repository}.git`

      const s = spinner()
      s.start(`Fetching ${config.owner}/${config.repository}...`)

      try {
        await spawn("git", [
          "clone",
          repoUrl,
          tempDir,
          "--branch",
          config.branch,
          "--depth",
          "1",
          "--single-branch",
          ...(options.recursive ? ["--recursive"] : []),
        ])
      } catch {
        await spawn("git", [
          "clone",
          repoUrl,
          tempDir,
          ...(options.recursive ? ["--recursive"] : []),
        ])
        await spawn("git", ["checkout", config.branch], { cwd: tempDir })
      }

      // Walk local tree to build entries (scoped to config.path if set)
      const walkRoot = config.path ? path.join(tempDir, config.path) : tempDir
      const entries: TreeEntry[] = []
      async function walkDir(dir: string, rel: string) {
        const items = await fs.promises.readdir(dir, { withFileTypes: true })
        for (const item of items) {
          if (item.name === ".git") continue
          const itemRel = rel ? `${rel}/${item.name}` : item.name
          const itemPath = path.join(dir, item.name)
          if (item.isSymbolicLink()) {
            const linkTarget = await fs.promises.readlink(itemPath)
            let resolvedIsDir = false
            try {
              resolvedIsDir = (await fs.promises.stat(itemPath)).isDirectory()
            } catch {}
            entries.push({
              path: itemRel,
              type: "symlink",
              linkTarget: resolvedIsDir ? linkTarget + "/" : linkTarget,
            })
          } else if (item.isDirectory()) {
            entries.push({ path: itemRel, type: "tree" })
            await walkDir(itemPath, itemRel)
          } else {
            const stat = await fs.promises.stat(itemPath)
            entries.push({ path: itemRel, type: "blob", size: stat.size })
          }
        }
      }
      await walkDir(walkRoot, "")

      s.success(`Fetched ${config.owner}/${config.repository} (${entries.length} entries)`)

      if (!entries.length) {
        await fs.promises.rm(tempDir, { recursive: true, force: true })
        console.log(yellow("\nRepository has no files."))
        process.exit(0)
      }

      const selected = await interactivePicker(
        entries,
        `${config.owner}/${config.repository} ${cyan("repository:" + config.branch)} > ${green(config.target)}`,
        walkRoot,
      )

      if (!selected.length) {
        await fs.promises.rm(tempDir, { recursive: true, force: true })
        console.log("\nNo files selected.")
        process.exit(0)
      }

      // Dry run - just show what would be picked
      if (options.dryRun) {
        console.log(
          `\n${green("✔")} Would pick ${selected.length} path${selected.length !== 1 ? "s" : ""}:`,
        )
        for (const sel of selected) console.log(`  ${sel}`)
        await fs.promises.rm(tempDir, { recursive: true, force: true })
        console.log()
        notifyUpdate(version, false)
        process.exit(0)
      }

      console.log(
        `\n${green("✔")} Picking ${selected.length} selected path${selected.length !== 1 ? "s" : ""}...`,
      )

      // Overwrite guard
      if (fs.existsSync(targetPath) && !options.overwrite) {
        if ((await fs.promises.readdir(targetPath)).length) {
          await fs.promises.rm(tempDir, { recursive: true, force: true })
          console.log(
            `${yellow(`\nWarning: The target directory exists at ${green(config.target)} and is not empty. Use ${cyan("-f")} or ${cyan("-o")} to overwrite.`)}`,
          )
          process.exit(1)
        }
      }

      await fs.promises.mkdir(targetPath, { recursive: true })

      let copiedFiles = 0
      for (const sel of selected) {
        const src = path.join(walkRoot, sel)
        const dest = path.join(targetPath, sel)
        const stat = await fs.promises.stat(src).catch(() => null)
        if (!stat) continue

        if (stat.isDirectory()) {
          await fs.promises.mkdir(dest, { recursive: true })
          const files = await copyDir(src, dest)
          copiedFiles += files.length
        } else {
          await fs.promises.mkdir(path.dirname(dest), { recursive: true })
          await fs.promises.copyFile(src, dest)
          copiedFiles++
        }
      }

      await fs.promises.rm(tempDir, { recursive: true, force: true })

      console.log(
        green(
          `✔ Copied ${copiedFiles} file${copiedFiles !== 1 ? "s" : ""} to ${displayPath(targetPath)}`,
        ),
      )
      if (options.tree) {
        process.stdout.write(`\n${bold(cyan(displayPath(targetPath)))}\n`)
        await printTree(targetPath)
        process.stdout.write("\n")
      }
      notifyUpdate(version, false)
      process.exit(0)
    }

    const renderTree = async (clonedPath: string) => {
      if (fs.statSync(clonedPath).isDirectory()) {
        process.stdout.write(`${bold(cyan(displayPath(targetPath)))}\n`)
        await printTree(clonedPath)
      } else {
        process.stdout.write(`${bold(cyan(displayPath(path.dirname(targetPath))))}\n`)
        process.stdout.write(`└── ${path.basename(targetPath)}\n`)
      }
      process.stdout.write("\n")
    }

    if (options.dryRun) {
      if (options.tree) {
        const tempTarget = path.resolve(
          os.tmpdir(),
          `gitpick-dry-${Date.now()}${Math.random().toString(16).slice(2, 6)}`,
        )
        try {
          await cloneAction(config, options, tempTarget)
          await renderTree(tempTarget)
        } finally {
          await fs.promises.rm(tempTarget, { recursive: true, force: true })
        }
      }
      if (!silent) console.log()
      notifyUpdate(version, silent)
      process.exit(0)
    }
    options.overwrite = options.overwrite || options.force
    if (options.watch) options.overwrite = true

    if (fs.existsSync(targetPath) && !options.overwrite) {
      if (config.type === "blob") {
        console.log(
          `${yellow(`\nWarning: The target file exists at ${green(config.target)}. Use ${cyan("-f")} or ${cyan("-o")} to overwrite.`)}`,
        )
        process.exit(1)
      }
      if ((await fs.promises.readdir(targetPath)).length) {
        console.log(
          `${yellow(`\nWarning: The target directory exists at ${green(config.target)} and is not empty. Use ${cyan("-f")} or ${cyan("-o")} to overwrite.`)}`,
        )
        process.exit(1)
      }
    }

    if (options.watch) {
      if (!silent)
        console.log(`\n👀 Watching every ${parseTimeString(options.watch) / 1000 + "s"}\n`)
      await cloneAction(config, options, targetPath)
      if (options.tree) await renderTree(targetPath)
      const watchInterval = parseTimeString(options.watch)
      setInterval(async () => {
        await cloneAction(config, options, targetPath)
        if (options.tree) await renderTree(targetPath)
      }, watchInterval)
    } else {
      await cloneAction(config, options, targetPath)
      if (options.tree) await renderTree(targetPath)
      notifyUpdate(version, silent)
      process.exit(0)
    }
  } catch (err) {
    if (err instanceof Error) {
      console.log(bold(`\n${red("Error: ")}`) + err.message)
    } else {
      console.log(bold(`${red("\nUnexpected Error: ")}`) + JSON.stringify(err, null, 2))
    }
    process.exit(1)
  }
}

main()


================================================
FILE: bin/utils/clone-action.ts
================================================
import fs from "node:fs"
import os from "node:os"
import path from "node:path"

import spawn from "@/external/nano-spawn"
import { spinner } from "@/external/yocto-spinner"
import { cyan, dim } from "@/external/yoctocolors"
import { copyDir } from "@/utils/copy-dir"

const activeTempDirs = new Set<string>()

function cleanupAndExit() {
  for (const dir of activeTempDirs) {
    try {
      fs.rmSync(dir, { recursive: true, force: true })
    } catch {}
  }
  process.exit(1)
}

process.on("SIGINT", cleanupAndExit)
process.on("SIGTERM", cleanupAndExit)

const formatSize = (bytes: number) => {
  if (bytes < 1024) return `${bytes} B`
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}

export type CloneResult = {
  files: string[]
  duration: number
  networkTime: number
  copyTime: number
  totalSize: number
  cloneStrategy: string
}

export const cloneAction = async (
  config: {
    token: string
    host: string
    owner: string
    repository: string
    branch: string
    type: string
    path: string
  },
  options: {
    recursive?: boolean
    watch?: string
    quiet?: boolean
    tree?: boolean
    verbose?: boolean
  },
  targetPath: string,
): Promise<CloneResult> => {
  const silent = options.tree || options.quiet
  const verbose = options.verbose && !silent

  if (process.platform === "win32") {
    await spawn("git", ["config", "--global", "core.longpaths", "true"])
  }

  const repoUrl = `https://${config.token ? config.token + "@" : config.token}${config.host}/${config.owner}/${config.repository}.git`
  const displayUrl = `https://${config.host}/${config.owner}/${config.repository}.git`
  const tempDir = path.resolve(
    os.tmpdir(),
    `${config.repository}-${Date.now()}${Math.random().toString(16).slice(2, 6)}`,
  )

  activeTempDirs.add(tempDir)

  const s = spinner()
  const start = performance.now()

  if (!options.watch && !silent) {
    s.start(
      `Picking ${config.type}${config.type === "repository" ? " without .git" : " from repository"}...`,
    )
  }

  let cloneStrategy = "shallow"
  const networkStart = performance.now()

  try {
    await spawn("git", [
      "clone",
      repoUrl,
      tempDir,
      "--branch",
      config.branch,
      "--depth",
      "1",
      "--single-branch",
      ...(options.recursive ? ["--recursive"] : []),
    ])
  } catch {
    cloneStrategy = "full"
    await spawn("git", ["clone", repoUrl, tempDir, ...(options.recursive ? ["--recursive"] : [])])
    await spawn("git", ["checkout", config.branch], { cwd: tempDir })
  }

  const networkTime = Number(((performance.now() - networkStart) / 1000).toFixed(2))

  const sourcePath = path.resolve(tempDir, config.path)

  const sourceStat = await fs.promises.stat(sourcePath)

  let files: string[] = []
  const copyStart = performance.now()

  if (sourceStat.isDirectory()) {
    await fs.promises.mkdir(targetPath, { recursive: true })
    files = await copyDir(sourcePath, targetPath)
  } else {
    await fs.promises.mkdir(path.dirname(targetPath), {
      recursive: true,
    })
    await fs.promises.copyFile(sourcePath, targetPath)
    files = [path.basename(targetPath)]
  }

  const copyTime = Number(((performance.now() - copyStart) / 1000).toFixed(2))
  const duration = Number(((performance.now() - start) / 1000).toFixed(2))

  let totalSize = 0
  for (const file of files) {
    try {
      const stat = await fs.promises.stat(path.join(targetPath, file))
      totalSize += stat.size
    } catch {
      // single file (blob) — targetPath is the file itself
      const stat = await fs.promises.stat(targetPath)
      totalSize += stat.size
      break
    }
  }

  if (!silent) {
    if (!options.watch) {
      s.success(
        `Picked ${config.type}${config.type === "repository" ? " without .git" : " from repository"} in ${duration} seconds.`,
      )
    } else console.log("- Synced at " + new Date().toLocaleTimeString())
  }

  if (verbose) {
    console.log(
      dim(`  clone:    ${cloneStrategy} (depth=${cloneStrategy === "shallow" ? "1" : "full"})`),
    )
    console.log(dim(`  from:     ${displayUrl} @ ${cyan(config.branch)}`))
    console.log(dim(`  to:       ${targetPath}`))
    console.log(dim(`  files:    ${files.length} (${formatSize(totalSize)})`))
    console.log(dim(`  network:  ${networkTime}s`))
    console.log(dim(`  copy:     ${copyTime}s`))
    console.log(dim(`  total:    ${duration}s`))
  }

  await fs.promises.rm(tempDir, { recursive: true, force: true })
  activeTempDirs.delete(tempDir)

  return { files, duration, networkTime, copyTime, totalSize, cloneStrategy }
}


================================================
FILE: bin/utils/copy-dir.ts
================================================
import fs from "node:fs"
import path from "node:path"

export const copyDir = async (
  src: string,
  dest: string,
  relativeTo?: string,
): Promise<string[]> => {
  const base = relativeTo ?? dest
  const entries = await fs.promises.readdir(src, { withFileTypes: true })
  await fs.promises.mkdir(dest, { recursive: true })

  const files: string[] = []

  for (const entry of entries) {
    if (entry.name === ".git") continue
    const srcPath = path.join(src, entry.name)
    const destPath = path.join(dest, entry.name)

    if (entry.isDirectory()) {
      files.push(...(await copyDir(srcPath, destPath, base)))
    } else if (entry.isSymbolicLink()) {
      const link = await fs.promises.readlink(srcPath)
      await fs.promises.symlink(link, destPath)
      files.push(path.relative(base, destPath))
    } else {
      await fs.promises.copyFile(srcPath, destPath)
      files.push(path.relative(base, destPath))
    }
  }

  return files
}


================================================
FILE: bin/utils/get-default-branch.ts
================================================
import spawn from "@/external/nano-spawn"

export const getDefaultBranch = async (url: string) => {
  const remotes = (await spawn("git", ["ls-remote", url])).stdout
  const headHash = remotes.match(/(.+)\s+HEAD/)?.[1]
  const branch = remotes.match(new RegExp(`${headHash}\\s+refs/heads/(.+)`))?.[1]
  if (!branch) {
    throw new Error("Could not determine default branch!")
  }
  return branch
}


================================================
FILE: bin/utils/interactive-picker.ts
================================================
import fs from "node:fs"
import path from "node:path"

import { highlightText } from "@/external/speed-highlight"
import { bold, cyan, dim, green, yellow } from "@/external/yoctocolors"

const EXT_TO_LANG: Record<string, string> = {
  ".js": "js",
  ".mjs": "js",
  ".cjs": "js",
  ".jsx": "js",
  ".ts": "ts",
  ".mts": "ts",
  ".cts": "ts",
  ".tsx": "ts",
  ".json": "json",
  ".jsonc": "json",
  ".md": "md",
  ".mdx": "md",
  ".css": "css",
  ".scss": "css",
  ".html": "html",
  ".htm": "html",
  ".svelte": "html",
  ".vue": "html",
  ".xml": "xml",
  ".svg": "xml",
  ".yaml": "yaml",
  ".yml": "yaml",
  ".toml": "toml",
  ".py": "py",
  ".rs": "rs",
  ".go": "go",
  ".c": "c",
  ".h": "c",
  ".cpp": "c",
  ".hpp": "c",
  ".java": "java",
  ".sql": "sql",
  ".sh": "bash",
  ".bash": "bash",
  ".zsh": "bash",
  ".lua": "lua",
  ".pl": "pl",
  ".pm": "pl",
  ".rb": "py", // ruby is close enough to py highlighting
  ".diff": "diff",
  ".patch": "diff",
  ".ini": "ini",
  ".cfg": "ini",
  ".env": "ini",
  ".dockerfile": "docker",
  ".makefile": "make",
  ".csv": "csv",
  ".log": "log",
}

function detectLang(filename: string): string {
  const ext = path.extname(filename).toLowerCase()
  if (ext) return EXT_TO_LANG[ext] || "plain"
  const base = path.basename(filename).toLowerCase()
  if (base === "dockerfile") return "docker"
  if (base === "makefile") return "make"
  if (base === ".gitignore" || base === ".env") return "ini"
  return "plain"
}

export type TreeEntry = {
  path: string
  type: "blob" | "tree" | "symlink"
  size?: number
  linkTarget?: string
}

const stripAnsi = (s: string) => s.replace(/\x1B\[\d+(?:;\d+)*m/g, "")

function truncateAnsi(s: string, maxWidth: number): string {
  let visible = 0
  let i = 0
  while (i < s.length && visible < maxWidth) {
    if (s[i] === "\x1B" && s[i + 1] === "[") {
      const end = s.indexOf("m", i)
      if (end !== -1) {
        i = end + 1
        continue
      }
    }
    visible++
    i++
  }
  return s.slice(0, i) + "\x1B[0m"
}

type TreeNode = {
  name: string
  path: string
  type: "blob" | "tree" | "symlink"
  size: number
  linkTarget: string
  children: TreeNode[]
  expanded: boolean
  selected: boolean
  depth: number
}

function buildTree(entries: TreeEntry[]): TreeNode[] {
  const root: TreeNode[] = []
  const dirs = new Map<string, TreeNode>()

  // Sort so directories come before files, then alphabetically
  const sorted = [...entries].sort((a, b) => {
    if (a.type !== b.type) return a.type === "tree" ? -1 : 1
    return a.path.localeCompare(b.path, undefined, { sensitivity: "base" })
  })

  for (const entry of sorted) {
    const parts = entry.path.split("/")
    const name = parts[parts.length - 1]
    const node: TreeNode = {
      name,
      path: entry.path,
      type: entry.type,
      size: entry.size || 0,
      linkTarget: entry.linkTarget || "",
      children: [],
      expanded: false,
      selected: false,
      depth: parts.length - 1,
    }

    if (entry.type === "tree") {
      dirs.set(entry.path, node)
    }

    if (parts.length === 1) {
      root.push(node)
    } else {
      const parentPath = parts.slice(0, -1).join("/")
      const parent = dirs.get(parentPath)
      if (parent) {
        parent.children.push(node)
      } else {
        // Parent directory wasn't in the tree entries - create implicit ones
        let currentPath = ""
        let currentList = root
        for (let i = 0; i < parts.length - 1; i++) {
          currentPath = currentPath ? currentPath + "/" + parts[i] : parts[i]
          let dir = dirs.get(currentPath)
          if (!dir) {
            dir = {
              name: parts[i],
              path: currentPath,
              type: "tree",
              size: 0,
              linkTarget: "",
              children: [],
              expanded: false,
              selected: false,
              depth: i,
            }
            dirs.set(currentPath, dir)
            currentList.push(dir)
          }
          currentList = dir.children
        }
        currentList.push(node)
      }
    }
  }

  // Sort children: dirs first, then files, alphabetically within each group
  function sortChildren(nodes: TreeNode[]) {
    nodes.sort((a, b) => {
      if (a.type !== b.type) return a.type === "tree" ? -1 : 1
      return a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
    })
    for (const n of nodes) {
      if (n.children.length) sortChildren(n.children)
    }
  }
  sortChildren(root)

  // Calculate folder sizes from children
  function calcSize(nodes: TreeNode[]): number {
    let total = 0
    for (const node of nodes) {
      if (node.children.length) {
        node.size = calcSize(node.children)
      }
      total += node.size
    }
    return total
  }
  calcSize(root)

  return root
}

type FlatItem = {
  node: TreeNode
  prefix: string
  connector: string
}

function flatten(roots: TreeNode[]): FlatItem[] {
  const items: FlatItem[] = []

  function walk(nodes: TreeNode[], prefix: string) {
    for (let i = 0; i < nodes.length; i++) {
      const node = nodes[i]
      const last = i === nodes.length - 1
      const connector = last ? "└── " : "├── "
      items.push({ node, prefix, connector })
      if (node.type === "tree" && node.expanded) {
        walk(node.children, prefix + (last ? "    " : "│   "))
      }
    }
  }

  walk(roots, "")
  return items
}

function setSelected(node: TreeNode, value: boolean) {
  node.selected = value
  for (const child of node.children) {
    setSelected(child, value)
  }
}

function resolveSymlinkPath(symlinkPath: string, linkTarget: string): string {
  const symlinkDir = symlinkPath.includes("/") ? symlinkPath.split("/").slice(0, -1).join("/") : ""
  const rawTarget = linkTarget.replace(/\/$/, "")
  const resolved = symlinkDir ? `${symlinkDir}/${rawTarget}` : rawTarget
  const parts = resolved.split("/")
  const normalized: string[] = []
  for (const p of parts) {
    if (p === "..") normalized.pop()
    else if (p !== ".") normalized.push(p)
  }
  return normalized.join("/")
}

function findNodeByPath(roots: TreeNode[], targetPath: string): TreeNode | null {
  for (const node of roots) {
    if (node.path === targetPath) return node
    if (node.children.length) {
      const found = findNodeByPath(node.children, targetPath)
      if (found) return found
    }
  }
  return null
}

function updateParentSelection(roots: TreeNode[]) {
  function walk(nodes: TreeNode[]): void {
    for (const node of nodes) {
      if (node.children.length) {
        walk(node.children)
        node.selected = node.children.every((c) => c.selected)
      }
    }
  }
  walk(roots)
}

function collectSelected(nodes: TreeNode[]): string[] {
  const paths: string[] = []

  function walk(nodeList: TreeNode[]) {
    for (const node of nodeList) {
      if (node.selected) {
        if (node.type === "tree") {
          // If a whole directory is selected, add the dir path
          // Only add the dir itself if ALL children are selected
          const allChildrenSelected =
            node.children.length > 0 && node.children.every((c) => c.selected)
          if (allChildrenSelected || node.children.length === 0) {
            paths.push(node.path)
          } else {
            walk(node.children)
          }
        } else {
          paths.push(node.path)
        }
      } else if (node.type === "tree") {
        // Dir not selected but maybe some children are
        walk(node.children)
      }
    }
  }

  walk(nodes)
  return paths
}

function countSelected(nodes: TreeNode[]): {
  files: number
  folders: number
  symlinks: number
  size: number
} {
  let files = 0
  let folders = 0
  let symlinks = 0
  let size = 0

  function walk(nodeList: TreeNode[]) {
    for (const node of nodeList) {
      if (node.selected) {
        if (node.type === "tree") folders++
        else if (node.type === "symlink") symlinks++
        else {
          files++
          size += node.size
        }
      }
      if (node.children.length) walk(node.children)
    }
  }

  walk(nodes)
  return { files, folders, symlinks, size }
}

const formatSize = (bytes: number) => {
  if (bytes < 1024) return `${bytes} B`
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}

export function interactivePicker(
  entries: TreeEntry[],
  label: string,
  basePath?: string,
): Promise<string[]> {
  return new Promise((resolve) => {
    const tree = buildTree(entries)
    if (!tree.length) {
      resolve([])
      return
    }

    // Auto-expand: if ≤30 entries expand all, otherwise expand up to depth 1 (2 levels)
    function expandToDepth(nodes: TreeNode[], maxDepth: number, currentDepth = 0) {
      for (const node of nodes) {
        if (node.type === "tree" && currentDepth <= maxDepth) {
          node.expanded = true
          expandToDepth(node.children, maxDepth, currentDepth + 1)
        }
      }
    }

    if (entries.length <= 30) {
      expandToDepth(tree, Infinity)
    } else {
      expandToDepth(tree, 1)
    }

    let cursor = 0
    let scrollOffset = 0
    const stream = process.stdout
    const stdin = process.stdin

    const wasRaw = stdin.isRaw
    stdin.setRawMode(true)
    stdin.resume()

    // Enter alternate screen, hide cursor
    stream.write("\x1B[?1049h\x1B[?25l")

    function cleanup() {
      stdin.setRawMode(wasRaw ?? false)
      stdin.pause()
      stdin.removeListener("data", onKey)
      stream.removeListener("resize", onResize)
      // Exit alternate screen, show cursor
      stream.write("\x1B[?25h\x1B[?1049l")
    }

    // Safety net for clean terminal restore
    const onExit = () => {
      stream.write("\x1B[?25h\x1B[?1049l")
    }
    const onSigint = () => {
      cleanup()
      process.removeListener("exit", onExit)
      process.removeListener("SIGINT", onSigint)
      resolve([])
      process.exit(0)
    }
    let inPreview = false
    let previewRenderer: (() => void) | null = null
    const onResize = () => {
      if (inPreview && previewRenderer) previewRenderer()
      else render()
    }
    process.on("exit", onExit)
    process.on("SIGINT", onSigint)
    stream.on("resize", onResize)

    function render() {
      const rows = stream.rows || 24
      const cols = stream.columns || 80
      const headerLines = 3 // blank + label + blank
      const dotRowLines = 1
      const footerLines = 5
      const treeViewportHeight = Math.max(1, rows - headerLines - dotRowLines - footerLines)

      const items = flatten(tree)

      // Scroll only applies to tree items (cursor > 0)
      const treeCursor = cursor - 1 // -1 because 0 is dot row
      if (treeCursor >= 0) {
        if (treeCursor < scrollOffset) scrollOffset = treeCursor
        if (treeCursor >= scrollOffset + treeViewportHeight)
          scrollOffset = treeCursor - treeViewportHeight + 1
      } else {
        scrollOffset = 0
      }
      if (scrollOffset < 0) scrollOffset = 0

      const visible = items.slice(scrollOffset, scrollOffset + treeViewportHeight)
      const { files, folders, symlinks, size } = countSelected(tree)

      // Build output
      let out = "\x1B[H\x1B[2J" // cursor home + clear screen

      // Header
      out += `\n  ${label}\n\n`

      // "." select-all row (virtual row at index 0, tree items shift by 1)
      const allSelected = tree.every((n) => n.selected)
      const dotCursor = cursor === 0
      const dotCheckbox = allSelected ? green("●") : dim("○")
      let dotLine = `${dotCursor ? yellow(">") : " "} ${dotCheckbox} ${dim(".")}`
      if (dotCursor) {
        const pad = Math.max(0, cols - stripAnsi(dotLine).length)
        dotLine = `\x1B[48;5;236m${dotLine}${" ".repeat(pad)}\x1B[49m`
      }
      out += dotLine + "\n"

      // Tree items
      for (let i = 0; i < visible.length; i++) {
        const item = visible[i]
        const idx = scrollOffset + i + 1 // +1 for the dot row
        const isCursor = idx === cursor
        const checkbox = item.node.selected ? green("●") : dim("○")
        const nameStr =
          item.node.type === "tree"
            ? cyan(item.node.name + "/")
            : item.node.type === "symlink"
              ? yellow(item.node.name) +
                dim(" -> ") +
                (item.node.linkTarget.endsWith("/")
                  ? cyan(item.node.linkTarget)
                  : item.node.linkTarget)
              : item.node.name
        const expandIcon =
          item.node.type === "tree" ? (item.node.expanded ? dim("▾ ") : dim("▸ ")) : "  "
        const pointer = isCursor ? yellow(">") : " "
        const leftPart = `${pointer} ${checkbox} ${dim(item.prefix)}${dim(item.connector)}${expandIcon}${nameStr}`
        const sizeLabel =
          item.node.size > 0 && item.node.type !== "symlink" ? dim(formatSize(item.node.size)) : ""
        const leftLen = stripAnsi(leftPart).length
        const sizeLen = stripAnsi(sizeLabel).length
        const gap = Math.max(1, cols - leftLen - sizeLen - 1)
        let line = sizeLabel ? `${leftPart}${" ".repeat(gap)}${sizeLabel} ` : leftPart
        if (isCursor) {
          const pad = Math.max(0, cols - stripAnsi(line).length)
          line = `\x1B[48;5;236m${line}${" ".repeat(pad)}\x1B[49m`
        }
        out += line + "\n"
      }

      // Pad remaining viewport
      for (let i = visible.length; i < treeViewportHeight; i++) {
        out += "\n"
      }

      // Footer
      out += "\n"
      const scrollInfo =
        items.length > treeViewportHeight
          ? dim(
              ` • ${scrollOffset + 1}-${Math.min(scrollOffset + treeViewportHeight, items.length)}/${items.length}`,
            )
          : ""
      let statusLine: string
      if (allSelected) {
        statusLine = `  all selected ${dim("•")} ${dim(formatSize(size))}${scrollInfo}`
      } else if (files + folders + symlinks > 0) {
        const countParts: string[] = []
        if (folders > 0) countParts.push(cyan(`${folders} folder${folders !== 1 ? "s" : ""}`))
        if (files > 0) countParts.push(`${files} file${files !== 1 ? "s" : ""}`)
        if (symlinks > 0) countParts.push(yellow(`${symlinks} symlink${symlinks !== 1 ? "s" : ""}`))
        const metaParts: string[] = [countParts.join(" "), dim(formatSize(size))]
        statusLine = `  ${metaParts.join(dim(" • "))}${scrollInfo}`
      } else {
        statusLine = dim("  press . to select all") + scrollInfo
      }
      out += statusLine + "\n"
      out += "\n"
      const instructions = dim(
        basePath
          ? "↑↓:navigate  enter:expand/preview  space:select  c:confirm  q:quit"
          : "↑↓:navigate  enter:expand  space:select  c:confirm  q:quit",
      )
      out += `  ${instructions}\n`

      stream.write(out)
    }

    async function showPreview(node: TreeNode) {
      // Remove stdin listener immediately to prevent race during async highlight
      stdin.removeListener("data", onKey)

      const resolvedPath =
        node.type === "symlink" ? resolveSymlinkPath(node.path, node.linkTarget) : node.path
      const filePath = path.join(basePath!, resolvedPath)
      let content: string
      try {
        const stat = fs.statSync(filePath)
        if (stat.isDirectory()) {
          content = dim("(directory)")
        } else if (stat.size > 512 * 1024) {
          content = dim(`(file too large: ${formatSize(stat.size)})`)
        } else {
          const raw = fs.readFileSync(filePath)
          // Check if binary
          if (raw.includes(0)) {
            content = dim(`(binary file: ${formatSize(stat.size)})`)
          } else {
            const text = raw.toString("utf-8")
            const lang = detectLang(node.name)
            if (lang !== "plain") {
              try {
                content = await highlightText(text, lang)
              } catch {
                content = text
              }
            } else {
              content = text
            }
          }
        }
      } catch {
        content = dim("(unable to read file)")
      }

      let previewCursor = 0
      let previewScrollOffset = 0
      const lines = content.split("\n")
      const lineNumWidth = String(lines.length).length

      function renderPreview() {
        const rows = stream.rows || 24
        const cols = stream.columns || 80
        const headerLines = 3
        const footerLines = 3
        const viewportHeight = Math.max(1, rows - headerLines - footerLines)

        // Adjust scroll to follow cursor
        if (previewCursor < previewScrollOffset) previewScrollOffset = previewCursor
        if (previewCursor >= previewScrollOffset + viewportHeight)
          previewScrollOffset = previewCursor - viewportHeight + 1
        if (previewScrollOffset < 0) previewScrollOffset = 0

        let out = "\x1B[H\x1B[2J"
        const pathStr =
          node.type === "symlink" ? yellow(node.path) + dim(" -> ") + node.linkTarget : node.path
        out += `\n  ${bold(pathStr)} ${dim("•")} ${dim(formatSize(node.size))}\n\n`

        const visibleCount = Math.min(viewportHeight, lines.length - previewScrollOffset)
        for (let i = 0; i < visibleCount; i++) {
          const lineIdx = previewScrollOffset + i
          const isCursorLine = lineIdx === previewCursor
          const lineNum = dim(`  ${String(lineIdx + 1).padStart(lineNumWidth)}  `)
          const lineContent = truncateAnsi(lines[lineIdx], cols - lineNumWidth - 5)
          let line = `${lineNum}${lineContent}`
          if (isCursorLine) {
            // Strip trailing resets so bg extends fully
            const cleaned = line.replace(/\x1B\[0m/g, "")
            const pad = Math.max(0, cols - stripAnsi(cleaned).length)
            line = `\x1B[48;5;236m${cleaned}${" ".repeat(pad)}\x1B[0m`
          }
          out += line + "\n"
        }

        // Pad remaining
        for (let i = visibleCount; i < viewportHeight; i++) {
          out += "\n"
        }

        out += "\n"
        const scrollInfo =
          lines.length > viewportHeight
            ? dim(
                `${previewScrollOffset + 1}-${Math.min(previewScrollOffset + viewportHeight, lines.length)}/${lines.length}`,
              ) + dim(" • ")
            : ""
        const previewInstructions = dim("↑↓:navigate  esc/q:back")
        out += `  ${scrollInfo}${previewInstructions}\n`

        stream.write(out)
      }

      function onPreviewKey(buf: Buffer) {
        const key = buf.toString()

        // Ctrl-C in preview
        if (key === "\x03") {
          inPreview = false
          previewRenderer = null
          stdin.removeListener("data", onPreviewKey)
          cleanup()
          process.removeListener("exit", onExit)
          process.removeListener("SIGINT", onSigint)
          resolve([])
          return
        }

        if (key === "\x1B" || key === "q" || key === "Q" || key === "\r") {
          inPreview = false
          previewRenderer = null
          stdin.removeListener("data", onPreviewKey)
          stdin.on("data", onKey)
          render()
          return
        }

        if (key === "\x1B[A" || key === "k") {
          if (previewCursor > 0) previewCursor--
        }
        if (key === "\x1B[B" || key === "j") {
          if (previewCursor < lines.length - 1) previewCursor++
        }

        renderPreview()
      }

      inPreview = true
      previewRenderer = renderPreview
      stdin.on("data", onPreviewKey)
      renderPreview()
    }

    function onKey(buf: Buffer) {
      const items = flatten(tree)
      const totalRows = items.length + 1 // +1 for dot row
      const key = buf.toString()

      // Ctrl-C or q → quit
      if (key === "\x03" || key === "q" || key === "Q") {
        cleanup()
        process.removeListener("exit", onExit)
        process.removeListener("SIGINT", onSigint)
        resolve([])
        return
      }

      // c → confirm
      if (key === "c" || key === "C") {
        cleanup()
        process.removeListener("exit", onExit)
        process.removeListener("SIGINT", onSigint)
        resolve(collectSelected(tree))
        return
      }

      // Arrow up
      if (key === "\x1B[A" || key === "k") {
        if (cursor > 0) cursor--
      }

      // Arrow down
      if (key === "\x1B[B" || key === "j") {
        if (cursor < totalRows - 1) cursor++
      }

      // Space or Enter on dot row, or . anywhere → toggle select all
      if ((cursor === 0 && (key === " " || key === "\r")) || key === ".") {
        const allSelected = tree.every((n) => n.selected)
        for (const node of tree) setSelected(node, !allSelected)
      }

      // Space → toggle selection (tree items)
      if (key === " " && cursor > 0) {
        const item = items[cursor - 1]
        if (item) {
          const newValue = !item.node.selected
          setSelected(item.node, newValue)
          // If symlink selected, also select the target (but don't deselect it)
          if (newValue && item.node.type === "symlink" && item.node.linkTarget) {
            const targetPath = resolveSymlinkPath(item.node.path, item.node.linkTarget)
            const targetNode = findNodeByPath(tree, targetPath)
            if (targetNode) setSelected(targetNode, true)
          }
          updateParentSelection(tree)
        }
      }

      // Enter → expand/collapse directory, preview file, or jump to symlink target
      if (key === "\r" && cursor > 0) {
        const item = items[cursor - 1]
        if (item && item.node.type === "tree") {
          item.node.expanded = !item.node.expanded
        } else if (item && item.node.type === "symlink" && item.node.linkTarget.endsWith("/")) {
          // Symlink to folder - resolve relative path and jump to target
          const targetPath = resolveSymlinkPath(item.node.path, item.node.linkTarget)
          // Expand all ancestors so target is visible
          const pathParts = targetPath.split("/")
          for (let pi = 1; pi <= pathParts.length; pi++) {
            const ancestorPath = pathParts.slice(0, pi).join("/")
            const ancestor = findNodeByPath(tree, ancestorPath)
            if (ancestor && ancestor.type === "tree") ancestor.expanded = true
          }
          const targetNode = findNodeByPath(tree, targetPath)
          if (targetNode) {
            if (targetNode.type === "tree") targetNode.expanded = true
            const updatedItems = flatten(tree)
            const targetIdx = updatedItems.findIndex((fi) => fi.node === targetNode)
            if (targetIdx >= 0) cursor = targetIdx + 1 // +1 for dot row
          }
        } else if (
          item &&
          basePath &&
          (item.node.type === "blob" || item.node.type === "symlink")
        ) {
          showPreview(item.node)
          return
        }
      }

      // Right arrow / l → expand directory
      if ((key === "\x1B[C" || key === "l") && cursor > 0) {
        const item = items[cursor - 1]
        if (item && item.node.type === "tree") {
          item.node.expanded = true
        }
      }

      // Left arrow / h → collapse directory
      if ((key === "\x1B[D" || key === "h") && cursor > 0) {
        const item = items[cursor - 1]
        if (item && item.node.type === "tree") {
          item.node.expanded = false
        }
      }

      render()
    }

    stdin.on("data", onKey)
    render()
  })
}


================================================
FILE: bin/utils/parse-time-string.ts
================================================
export function parseTimeString(timeString: string | number): number {
  if (typeof timeString === "number" || /^\d+$/.test(timeString)) {
    return typeof timeString === "number" ? timeString : parseInt(timeString, 10)
  }

  const regex = /(\d+)([hms])/g
  let totalMilliseconds = 0
  let match: RegExpExecArray | null

  while ((match = regex.exec(timeString)) !== null) {
    const value = parseInt(match[1], 10)
    const unit = match[2] as "h" | "m" | "s"

    switch (unit) {
      case "h":
        totalMilliseconds += value * 3600000
        break
      case "m":
        totalMilliseconds += value * 60000
        break
      case "s":
        totalMilliseconds += value * 1000
        break
    }
  }

  return totalMilliseconds
}


================================================
FILE: bin/utils/transform-url.ts
================================================
import { getDefaultBranch } from "@/utils/get-default-branch"

type Host = "github.com" | "gitlab.com" | "bitbucket.org" | "codeberg.org"

const PREFIXES: { prefix: string; host: Host }[] = [
  { prefix: "git@github.com:", host: "github.com" },
  { prefix: "https://github.com/", host: "github.com" },
  { prefix: "https://raw.githubusercontent.com/", host: "github.com" },
  { prefix: "git@gitlab.com:", host: "gitlab.com" },
  { prefix: "https://gitlab.com/", host: "gitlab.com" },
  { prefix: "git@bitbucket.org:", host: "bitbucket.org" },
  { prefix: "https://bitbucket.org/", host: "bitbucket.org" },
  { prefix: "git@codeberg.org:", host: "codeberg.org" },
  { prefix: "https://codeberg.org/", host: "codeberg.org" },
]

export async function configFromUrl(
  url: string,
  {
    branch,
    target,
  }: {
    branch?: string | null
    target?: string | null
  },
) {
  const tokenRegex = /^https:\/\/([^@]+)@(github\.com|gitlab\.com|bitbucket\.org|codeberg\.org)/
  const tokenMatch = url.match(tokenRegex)

  let token = ""
  if (tokenMatch) {
    token = tokenMatch[1]
    url = url.replace(`${tokenMatch[1]}@`, "")
  } else {
    const hostTokens: Record<Host, string> = {
      "github.com": process.env.GITHUB_TOKEN || process.env.GH_TOKEN || "",
      "gitlab.com": process.env.GITLAB_TOKEN || "",
      "bitbucket.org": process.env.BITBUCKET_TOKEN || "",
      "codeberg.org": process.env.CODEBERG_TOKEN || "",
    }
    // Detect host early to pick the right env var
    for (const { prefix, host: h } of PREFIXES) {
      if (url.startsWith(prefix)) {
        token = hostTokens[h]
        break
      }
    }
    // Shorthand (no prefix) defaults to github.com
    if (!token && !url.startsWith("https://") && !url.startsWith("git@")) {
      token = hostTokens["github.com"]
    }
  }

  let host: Host = "github.com"

  for (const { prefix, host: h } of PREFIXES) {
    if (url.startsWith(prefix)) {
      host = h
      url = url.replace(prefix, "")
      break
    }
  }

  const split = url.split("/")
  const owner = split[0]
  const repository = split[1]?.endsWith(".git") ? split[1].slice(0, -4) : split[1]

  const repoUrl = `https://${token ? token + "@" : token}${host}/${owner}/${repository}`

  let type: string
  let resolvedBranch: string
  let resolvedPath: string

  if (host === "github.com") {
    if (split[2] === "refs" && ["heads", "tags"].includes(split[3])) {
      type = "raw"
      resolvedBranch = branch || split[4]
      resolvedPath = split.slice(5).join("/")
    } else if (split[2] === "refs" && split[3] === "remotes") {
      type = "raw"
      resolvedBranch = branch || `${split[4]}/${split[5]}`
      resolvedPath = split.slice(6).join("/")
    } else if (split[2] === "blob") {
      type = "blob"
      resolvedBranch = branch || split[3]
      resolvedPath = split.slice(4).join("/")
    } else if (split[2] === "tree") {
      type = "tree"
      resolvedBranch = branch || split[3]
      resolvedPath = split.slice(4).join("/")
    } else if (split[2] === "commit") {
      type = "repository"
      resolvedBranch = branch || split[3]
      resolvedPath = ""
    } else {
      type = "repository"
      resolvedBranch = branch || (await getDefaultBranch(repoUrl))
      resolvedPath = ""
    }
  } else if (host === "gitlab.com") {
    if (split[2] === "-" && split[3] === "blob") {
      type = "blob"
      resolvedBranch = branch || split[4]
      resolvedPath = split.slice(5).join("/")
    } else if (split[2] === "-" && split[3] === "tree") {
      type = "tree"
      resolvedBranch = branch || split[4]
      resolvedPath = split.slice(5).join("/")
    } else {
      type = "repository"
      resolvedBranch = branch || (await getDefaultBranch(repoUrl))
      resolvedPath = ""
    }
  } else if (host === "bitbucket.org") {
    // bitbucket.org — uses /src/branch/path for both files and dirs
    if (split[2] === "src") {
      type = "tree"
      resolvedBranch = branch || split[3]
      resolvedPath = split.slice(4).join("/")
    } else {
      type = "repository"
      resolvedBranch = branch || (await getDefaultBranch(repoUrl))
      resolvedPath = ""
    }
  } else if (host === "codeberg.org") {
    // codeberg.org — /src/<branch|tag|commit>/<ref>/path for files+dirs,
    // /raw/... and /media/... for direct file (blob) links
    if (
      ["src", "raw", "media"].includes(split[2]) &&
      ["branch", "tag", "commit"].includes(split[3])
    ) {
      type = split[2] === "src" ? "tree" : "blob"
      resolvedBranch = branch || split[4]
      resolvedPath = split.slice(5).join("/")
    } else {
      type = "repository"
      resolvedBranch = branch || (await getDefaultBranch(repoUrl))
      resolvedPath = ""
    }
  } else {
    const _exhaustive: never = host
    throw new Error(`Unsupported host: ${_exhaustive}`)
  }

  const resolvedTarget = target
    ? target
    : type === "blob"
      ? "."
      : resolvedPath.split("/").pop() || repository

  return {
    token,
    host,
    owner,
    repository,
    type,
    branch: resolvedBranch,
    path: resolvedPath,
    target: resolvedTarget,
  }
}


================================================
FILE: bin/utils/update-notifier.ts
================================================
import fs from "node:fs"
import https from "node:https"
import os from "node:os"
import path from "node:path"

import { bold, cyan, dim, yellow } from "@/external/yoctocolors"

const CACHE_DIR = path.join(os.homedir(), ".cache", "gitpick")
const CACHE_FILE = path.join(CACHE_DIR, "update-check.json")
const CHECK_INTERVAL = 24 * 60 * 60 * 1000 // 24 hours

type UpdateCache = {
  lastCheck: number
  latestVersion: string
}

function readCache(): UpdateCache | null {
  try {
    return JSON.parse(fs.readFileSync(CACHE_FILE, "utf-8"))
  } catch {
    return null
  }
}

function writeCache(cache: UpdateCache) {
  try {
    fs.mkdirSync(CACHE_DIR, { recursive: true })
    fs.writeFileSync(CACHE_FILE, JSON.stringify(cache))
  } catch {}
}

function fetchLatestVersion(): Promise<string | null> {
  return new Promise((resolve) => {
    const req = https.get(
      "https://registry.npmjs.org/gitpick/latest",
      { headers: { Accept: "application/json" }, timeout: 3000 },
      (res) => {
        if (res.statusCode !== 200) {
          res.resume()
          return resolve(null)
        }
        let data = ""
        res.on("data", (chunk) => (data += chunk))
        res.on("end", () => {
          try {
            resolve(JSON.parse(data).version || null)
          } catch {
            resolve(null)
          }
        })
      },
    )
    req.on("error", () => resolve(null))
    req.on("timeout", () => {
      req.destroy()
      resolve(null)
    })
  })
}

function isNewer(latest: string, current: string): boolean {
  const l = latest.split(".").map(Number)
  const c = current.split(".").map(Number)
  for (let i = 0; i < 3; i++) {
    if ((l[i] || 0) > (c[i] || 0)) return true
    if ((l[i] || 0) < (c[i] || 0)) return false
  }
  return false
}

/** Print update notice if a cached newer version is known. */
export function notifyUpdate(currentVersion: string, silent: boolean) {
  if (silent) return
  const cache = readCache()
  if (cache && isNewer(cache.latestVersion, currentVersion)) {
    console.log(
      dim(
        `\n  Update available: ${yellow(currentVersion)} → ${cyan(bold(cache.latestVersion))}` +
          `\n  Run ${cyan("npm i -g gitpick")} to update\n`,
      ),
    )
  }
}

/** Background check — fetch latest version and cache it. Does not block. */
export function scheduleUpdateCheck() {
  const cache = readCache()
  if (cache && Date.now() - cache.lastCheck < CHECK_INTERVAL) return

  setTimeout(async () => {
    const latest = await fetchLatestVersion()
    if (latest) {
      writeCache({ lastCheck: Date.now(), latestVersion: latest })
    }
  }, 0)
}


================================================
FILE: bin/utils/use-config.ts
================================================
import fs from "node:fs"
import path from "node:path"

import spawn from "@/external/nano-spawn"
import stripJsonComments from "@/external/strip-json-comments"

const configFiles = [".gitpick.json", ".gitpick.jsonc"]

export const useConfig = async () => {
  let configPath: string | undefined
  for (const file of configFiles) {
    const resolved = path.resolve(file)
    if (fs.existsSync(resolved)) {
      configPath = resolved
      break
    }
  }

  if (!configPath) return false

  const content = await fs.promises.readFile(configPath, "utf-8")
  const entries = JSON.parse(stripJsonComments(content))

  if (!Array.isArray(entries) || !entries.every((e: unknown) => typeof e === "string")) {
    throw new Error(`${path.basename(configPath)} must be an array of strings`)
  }

  for (const entry of entries) {
    await spawn(process.argv[0], [...process.argv.slice(1), ...entry.split(/\s+/), "-o"], {
      stdio: "inherit",
    })
  }

  return true
}


================================================
FILE: lefthook.yml
================================================
pre-commit:
  piped: true
  commands:
    lint-staged:
      run: bunx lint-staged --verbose
      stage_fixed: true

commit-msg:
  commands:
    commitlint:
      run: bunx commitlint --edit {1}


================================================
FILE: package.json
================================================
{
  "name": "gitpick",
  "version": "5.4.1",
  "description": "Clone exactly what you need aka straightforward project scaffolding!",
  "keywords": [
    "clone",
    "degit",
    "directory",
    "file",
    "folder",
    "git",
    "github",
    "repository",
    "scaffolding",
    "template",
    "url"
  ],
  "homepage": "https://github.com/nrjdalal/gitpick#readme",
  "bugs": "https://github.com/nrjdalal/gitpick/issues",
  "license": "MIT",
  "author": {
    "name": "Neeraj Dalal",
    "email": "admin@nrjdalal.com",
    "url": "https://nrjdalal.com"
  },
  "repository": "nrjdalal/gitpick",
  "funding": "https://github.com/sponsors/nrjdalal",
  "bin": {
    "degit": "./dist/index.mjs",
    "gitpick": "./dist/index.mjs"
  },
  "files": [
    "dist"
  ],
  "type": "module",
  "scripts": {
    "build": "tsdown",
    "dev": "tsdown --watch",
    "format": "oxfmt",
    "format:check": "oxfmt --check",
    "prepare": "bunx lefthook install",
    "test": "tsdown && bun test tests/cli.test.ts"
  },
  "devDependencies": {
    "@commitlint/cli": "^20.5.0",
    "@commitlint/config-conventional": "^20.5.0",
    "@types/node": "^25.5.0",
    "lefthook": "^2.1.4",
    "lint-staged": "^16.4.0",
    "oxfmt": "^0.41.0",
    "oxlint": "^1.56.0",
    "sort-package-json": "^3.6.1",
    "tsdown": "^0.21.4",
    "typescript": "^5.9.3"
  },
  "commitlint": {
    "extends": [
      "@commitlint/config-conventional"
    ]
  }
}


================================================
FILE: tests/cli.test.ts
================================================
import { beforeAll, describe, expect, it } from "bun:test"
import {
  copyFileSync,
  existsSync,
  lstatSync,
  mkdirSync,
  readFileSync,
  readlinkSync,
  readdirSync,
  rmSync,
  writeFileSync,
} from "node:fs"
import { join, resolve } from "node:path"

const CLI = ["node", resolve("dist/index.mjs")]
const ARTIFACTS = ".test-artifacts"

// --- helpers ---

function stripAnsi(s: string) {
  // eslint-disable-next-line no-control-regex
  return s.replace(/\x1b\[[0-9;]*m/g, "").replace(/\x1b\]8;;[^\x07]*\x07/g, "")
}

async function run(args: string[], cwd?: string) {
  const proc = Bun.spawn([...CLI, ...args], {
    stdout: "pipe",
    stderr: "pipe",
    cwd,
  })
  const [stdout, stderr] = await Promise.all([
    new Response(proc.stdout).text(),
    new Response(proc.stderr).text(),
  ])
  return { output: stdout + stderr, exitCode: await proc.exited }
}

function parseLine(output: string) {
  return (
    stripAnsi(output)
      .split("\n")
      .find((l) => l.includes("✔")) || ""
  )
}

function tree(dir: string, prefix = ""): string {
  const entries = readdirSync(dir, { withFileTypes: true })
    .filter((e) => e.name !== ".git")
    .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }))
  let out = ""
  entries.forEach((e, i) => {
    const last = i === entries.length - 1
    const connector = last ? "└── " : "├── "
    if (e.isSymbolicLink()) {
      out += `${prefix}${connector}${e.name} -> ${readlinkSync(join(dir, e.name))}\n`
    } else if (e.isDirectory()) {
      out += `${prefix}${connector}${e.name}\n`
      out += tree(join(dir, e.name), `${prefix}${last ? "    " : "│   "}`)
    } else {
      out += `${prefix}${connector}${e.name}\n`
    }
  })
  return out
}

function getTree(dir: string) {
  if (!existsSync(dir)) return ""
  if (!lstatSync(dir).isDirectory()) return "(file)"
  return tree(dir).trimEnd()
}

// --- tree snapshots (sorted case-insensitive for bun's readdirSync) ---

const TREE_REPO_MAIN = [
  "├── file.txt",
  "├── folder",
  "│   ├── deep",
  "│   │   └── file.txt",
  "│   └── nested.txt",
  "├── README.md",
  "├── symdir -> folder",
  "└── symlink.txt -> file.txt",
].join("\n")

const TREE_REPO_DEV = [
  "├── dev.txt",
  "├── file.txt",
  "├── folder",
  "│   ├── deep",
  "│   │   └── file.txt",
  "│   └── nested.txt",
  "├── README.md",
  "├── symdir -> folder",
  "└── symlink.txt -> file.txt",
].join("\n")

const TREE_FOLDER = ["├── deep", "│   └── file.txt", "└── nested.txt"].join("\n")
const TREE_FOLDER_DEEP = "└── file.txt"
const TREE_BLOB_FILE = "└── file.txt"
const TREE_BLOB_NESTED = "└── nested.txt"
const TREE_BLOB_README = "└── README.md"

const TREE_GITLAB_REPO = [
  "├── .gitlab-ci.yml",
  "├── public",
  "│   ├── index.html",
  "│   └── style.css",
  "└── README.md",
].join("\n")

const TREE_GITLAB_PUBLIC = ["├── index.html", "└── style.css"].join("\n")

// --- test counter for unique artifact dirs ---

let n = 0
function target() {
  return join(ARTIFACTS, "cli", String(++n))
}

// --- high-level clone helper ---

async function cloneAndExpect(
  args: string[],
  expectedOutput: string,
  expectedTree: string,
  customTarget?: string,
) {
  const t = customTarget ? join(ARTIFACTS, "cli", customTarget) : target()
  if (existsSync(t) && !customTarget) rmSync(t, { recursive: true, force: true })

  const { output, exitCode } = await run(["clone", ...args, t])
  expect(parseLine(output)).toContain(expectedOutput)
  expect(exitCode).toBe(0)

  if (expectedTree === "(file)") {
    expect(lstatSync(t).isFile()).toBe(true)
  } else if (expectedTree) {
    expect(getTree(t)).toBe(expectedTree)
  } else {
    expect(existsSync(t)).toBe(true)
  }
}

// --- setup ---

beforeAll(() => {
  rmSync(join(ARTIFACTS, "cli"), { recursive: true, force: true })
  rmSync(join(ARTIFACTS, "config"), { recursive: true, force: true })
  mkdirSync(join(ARTIFACTS, "cli"), { recursive: true })
})

// =====================================================================
// DRY-RUN TESTS
// =====================================================================

describe("dry-run — URL parsing without cloning", () => {
  async function dryRun(args: string[], expected: string) {
    const { output, exitCode } = await run([...args, "--dry-run"])
    expect(exitCode).toBe(0)
    expect(parseLine(output)).toContain(expected)
  }

  // repo
  it(
    "shorthand repo",
    () => dryRun(["nrjdalal/picksuite"], "nrjdalal/picksuite repository:main > picksuite"),
    30000,
  )
  it(
    "full URL repo",
    () =>
      dryRun(
        ["https://github.com/nrjdalal/picksuite"],
        "nrjdalal/picksuite repository:main > picksuite",
      ),
    30000,
  )

  // tree
  it(
    "shorthand tree",
    () =>
      dryRun(
        ["nrjdalal/picksuite/tree/main/folder"],
        "nrjdalal/picksuite tree:main folder > folder",
      ),
    30000,
  )
  it(
    "full URL tree",
    () =>
      dryRun(
        ["https://github.com/nrjdalal/picksuite/tree/main/folder"],
        "nrjdalal/picksuite tree:main folder > folder",
      ),
    30000,
  )
  it(
    "nested tree",
    () =>
      dryRun(
        ["nrjdalal/picksuite/tree/main/folder/deep"],
        "nrjdalal/picksuite tree:main folder/deep > deep",
      ),
    30000,
  )
  it(
    "full URL nested tree",
    () =>
      dryRun(
        ["https://github.com/nrjdalal/picksuite/tree/main/folder/deep"],
        "nrjdalal/picksuite tree:main folder/deep > deep",
      ),
    30000,
  )

  // blob
  it(
    "shorthand blob",
    () =>
      dryRun(
        ["nrjdalal/picksuite/blob/main/file.txt"],
        "nrjdalal/picksuite blob:main file.txt > ./file.txt",
      ),
    30000,
  )
  it(
    "full URL blob",
    () =>
      dryRun(
        ["https://github.com/nrjdalal/picksuite/blob/main/file.txt"],
        "nrjdalal/picksuite blob:main file.txt > ./file.txt",
      ),
    30000,
  )
  it(
    "nested blob",
    () =>
      dryRun(
        ["nrjdalal/picksuite/blob/main/folder/nested.txt"],
        "nrjdalal/picksuite blob:main folder/nested.txt > ./nested.txt",
      ),
    30000,
  )
  it(
    "deep nested blob",
    () =>
      dryRun(
        ["https://github.com/nrjdalal/picksuite/blob/main/folder/deep/file.txt"],
        "nrjdalal/picksuite blob:main folder/deep/file.txt > ./file.txt",
      ),
    30000,
  )

  // branch
  it(
    "-b dev",
    () =>
      dryRun(["nrjdalal/picksuite", "-b", "dev"], "nrjdalal/picksuite repository:dev > picksuite"),
    30000,
  )
  it(
    "full URL -b dev",
    () =>
      dryRun(
        ["https://github.com/nrjdalal/picksuite", "-b", "dev"],
        "nrjdalal/picksuite repository:dev > picksuite",
      ),
    30000,
  )
  it(
    "tree/dev",
    () => dryRun(["nrjdalal/picksuite/tree/dev"], "nrjdalal/picksuite tree:dev > picksuite"),
    30000,
  )
  it(
    "full URL tree/dev",
    () =>
      dryRun(
        ["https://github.com/nrjdalal/picksuite/tree/dev"],
        "nrjdalal/picksuite tree:dev > picksuite",
      ),
    30000,
  )

  // commit SHA
  it(
    "-b SHA",
    () =>
      dryRun(
        ["nrjdalal/picksuite", "-b", "8af536b"],
        "nrjdalal/picksuite repository:8af536b > picksuite",
      ),
    30000,
  )
  it(
    "/commit/ URL",
    () =>
      dryRun(
        ["https://github.com/nrjdalal/picksuite/commit/8af536b"],
        "nrjdalal/picksuite repository:8af536b > picksuite",
      ),
    30000,
  )

  // submodules
  it(
    "-r shorthand",
    () => dryRun(["nrjdalal/picksuite", "-r"], "nrjdalal/picksuite repository:main > picksuite"),
    30000,
  )
  it(
    "-r full URL",
    () =>
      dryRun(
        ["https://github.com/nrjdalal/picksuite", "-r"],
        "nrjdalal/picksuite repository:main > picksuite",
      ),
    30000,
  )

  // token
  it(
    "token URL",
    () =>
      dryRun(
        ["https://fake_token@github.com/nrjdalal/picksuite"],
        "nrjdalal/picksuite repository:main > picksuite",
      ),
    30000,
  )

  // git@ and .git
  it(
    "git@",
    () =>
      dryRun(
        ["git@github.com:nrjdalal/picksuite.git"],
        "nrjdalal/picksuite repository:main > picksuite",
      ),
    30000,
  )
  it(
    ".git suffix",
    () =>
      dryRun(
        ["https://github.com/nrjdalal/picksuite.git"],
        "nrjdalal/picksuite repository:main > picksuite",
      ),
    30000,
  )
  it(
    "git@ tree",
    () =>
      dryRun(
        ["git@github.com:nrjdalal/picksuite.git/tree/main/folder"],
        "nrjdalal/picksuite tree:main folder > folder",
      ),
    30000,
  )
  it(
    ".git tree",
    () =>
      dryRun(
        ["https://github.com/nrjdalal/picksuite.git/tree/main/folder"],
        "nrjdalal/picksuite tree:main folder > folder",
      ),
    30000,
  )

  // branch override
  it(
    "branch override",
    () =>
      dryRun(
        ["nrjdalal/picksuite/tree/dev/folder", "-b", "main"],
        "nrjdalal/picksuite tree:main folder > folder",
      ),
    30000,
  )
  it(
    "branch override URL",
    () =>
      dryRun(
        ["https://github.com/nrjdalal/picksuite/tree/dev/folder", "-b", "main"],
        "nrjdalal/picksuite tree:main folder > folder",
      ),
    30000,
  )

  // custom target
  it(
    "custom target repo",
    () => dryRun(["nrjdalal/picksuite", "my-dir"], "nrjdalal/picksuite repository:main > my-dir"),
    30000,
  )
  it(
    "custom target tree",
    () =>
      dryRun(
        ["nrjdalal/picksuite/tree/main/folder", "my-folder"],
        "nrjdalal/picksuite tree:main folder > my-folder",
      ),
    30000,
  )

  // raw URL
  it(
    "raw URL refs/heads",
    () =>
      dryRun(
        ["https://raw.githubusercontent.com/nrjdalal/picksuite/refs/heads/main/file.txt"],
        "nrjdalal/picksuite raw:main file.txt > file.txt",
      ),
    30000,
  )
  it(
    "raw URL refs/tags",
    () =>
      dryRun(
        ["https://raw.githubusercontent.com/nrjdalal/picksuite/refs/tags/main/file.txt"],
        "nrjdalal/picksuite raw:main file.txt > file.txt",
      ),
    30000,
  )
  it(
    "raw shorthand refs/heads",
    () =>
      dryRun(
        ["nrjdalal/picksuite/refs/heads/main/file.txt"],
        "nrjdalal/picksuite raw:main file.txt > file.txt",
      ),
    30000,
  )
  it(
    "raw URL refs/remotes",
    () =>
      dryRun(
        ["https://raw.githubusercontent.com/nrjdalal/picksuite/refs/remotes/origin/main/file.txt"],
        "nrjdalal/picksuite raw:origin/main file.txt > file.txt",
      ),
    30000,
  )

  // gitlab
  it(
    "gitlab repo",
    () =>
      dryRun(
        ["https://gitlab.com/pages/plain-html", "-b", "main"],
        "pages/plain-html repository:main > plain-html",
      ),
    30000,
  )
  it(
    "gitlab tree",
    () =>
      dryRun(
        ["https://gitlab.com/pages/plain-html/-/tree/main/public"],
        "pages/plain-html tree:main public > public",
      ),
    30000,
  )
  it(
    "gitlab blob",
    () =>
      dryRun(
        ["https://gitlab.com/pages/plain-html/-/blob/main/README.md"],
        "pages/plain-html blob:main README.md > ./README.md",
      ),
    30000,
  )

  // bitbucket
  it(
    "bitbucket repo",
    () =>
      dryRun(
        ["https://bitbucket.org/snakeyaml/snakeyaml", "-b", "master"],
        "snakeyaml/snakeyaml repository:master > snakeyaml",
      ),
    30000,
  )
  it(
    "bitbucket src path",
    () =>
      dryRun(
        ["https://bitbucket.org/snakeyaml/snakeyaml/src/master/src"],
        "snakeyaml/snakeyaml tree:master src > src",
      ),
    30000,
  )

  // codeberg
  it(
    "codeberg repo",
    () =>
      dryRun(
        ["https://codeberg.org/Codeberg/avatars", "-b", "main"],
        "Codeberg/avatars repository:main > avatars",
      ),
    30000,
  )
  it(
    "codeberg src/branch path",
    () =>
      dryRun(
        ["https://codeberg.org/Codeberg/avatars/src/branch/main/example"],
        "Codeberg/avatars tree:main example > example",
      ),
    30000,
  )
  it(
    "codeberg src/tag path",
    () =>
      dryRun(
        ["https://codeberg.org/Codeberg/avatars/src/tag/v1.0.0/example"],
        "Codeberg/avatars tree:v1.0.0 example > example",
      ),
    30000,
  )
  it(
    "codeberg src/commit path",
    () =>
      dryRun(
        [
          "https://codeberg.org/Codeberg/avatars/src/commit/c86887927797ce57a7e4666494903a4e9b1e901c/example",
        ],
        "Codeberg/avatars tree:c86887927797ce57a7e4666494903a4e9b1e901c example > example",
      ),
    30000,
  )
  it(
    "codeberg raw/branch file",
    () =>
      dryRun(
        ["https://codeberg.org/Codeberg/avatars/raw/branch/main/README.md"],
        "Codeberg/avatars blob:main README.md > ./README.md",
      ),
    30000,
  )
  it(
    "codeberg media/branch file",
    () =>
      dryRun(
        ["https://codeberg.org/Codeberg/avatars/media/branch/main/README.md"],
        "Codeberg/avatars blob:main README.md > ./README.md",
      ),
    30000,
  )
  it(
    "codeberg git@ repo",
    () =>
      dryRun(
        ["git@codeberg.org:Codeberg/avatars", "-b", "main"],
        "Codeberg/avatars repository:main > avatars",
      ),
    30000,
  )
  it(
    "codeberg git@ src path",
    () =>
      dryRun(
        ["git@codeberg.org:Codeberg/avatars/src/branch/main/example"],
        "Codeberg/avatars tree:main example > example",
      ),
    30000,
  )
})

// =====================================================================
// CLI CLONE TESTS
// =====================================================================

describe("default — gitpick <url/shorthand>", () => {
  it(
    "shorthand repo",
    () =>
      cloneAndExpect(["nrjdalal/picksuite"], "nrjdalal/picksuite repository:main", TREE_REPO_MAIN),
    30000,
  )
  it(
    "full URL repo",
    () =>
      cloneAndExpect(
        ["https://github.com/nrjdalal/picksuite"],
        "nrjdalal/picksuite repository:main",
        TREE_REPO_MAIN,
      ),
    30000,
  )
  it(
    "git@ repo",
    () =>
      cloneAndExpect(
        ["git@github.com:nrjdalal/picksuite.git"],
        "nrjdalal/picksuite repository:main",
        TREE_REPO_MAIN,
      ),
    30000,
  )
  it(
    ".git suffix repo",
    () =>
      cloneAndExpect(
        ["https://github.com/nrjdalal/picksuite.git"],
        "nrjdalal/picksuite repository:main",
        TREE_REPO_MAIN,
      ),
    30000,
  )

  it(
    "shorthand tree",
    () =>
      cloneAndExpect(
        ["nrjdalal/picksuite/tree/main/folder"],
        "nrjdalal/picksuite tree:main folder",
        TREE_FOLDER,
      ),
    30000,
  )
  it(
    "full URL tree",
    () =>
      cloneAndExpect(
        ["https://github.com/nrjdalal/picksuite/tree/main/folder"],
        "nrjdalal/picksuite tree:main folder",
        TREE_FOLDER,
      ),
    30000,
  )
  it(
    "git@ tree",
    () =>
      cloneAndExpect(
        ["git@github.com:nrjdalal/picksuite.git/tree/main/folder"],
        "nrjdalal/picksuite tree:main folder",
        TREE_FOLDER,
      ),
    30000,
  )
  it(
    ".git suffix tree",
    () =>
      cloneAndExpect(
        ["https://github.com/nrjdalal/picksuite.git/tree/main/folder"],
        "nrjdalal/picksuite tree:main folder",
        TREE_FOLDER,
      ),
    30000,
  )
  it(
    "nested tree",
    () =>
      cloneAndExpect(
        ["nrjdalal/picksuite/tree/main/folder/deep"],
        "nrjdalal/picksuite tree:main folder/deep",
        TREE_FOLDER_DEEP,
      ),
    30000,
  )

  it(
    "shorthand blob",
    () =>
      cloneAndExpect(
        ["nrjdalal/picksuite/blob/main/file.txt"],
        "nrjdalal/picksuite blob:main file.txt",
        TREE_BLOB_FILE,
      ),
    30000,
  )
  it(
    "full URL blob",
    () =>
      cloneAndExpect(
        ["https://github.com/nrjdalal/picksuite/blob/main/file.txt"],
        "nrjdalal/picksuite blob:main file.txt",
        TREE_BLOB_FILE,
      ),
    30000,
  )
  it(
    "nested blob",
    () =>
      cloneAndExpect(
        ["nrjdalal/picksuite/blob/main/folder/nested.txt"],
        "nrjdalal/picksuite blob:main folder/nested.txt",
        TREE_BLOB_NESTED,
      ),
    30000,
  )
  it(
    "deep nested blob",
    () =>
      cloneAndExpect(
        ["https://github.com/nrjdalal/picksuite/blob/main/folder/deep/file.txt"],
        "nrjdalal/picksuite blob:main folder/deep/file.txt",
        TREE_BLOB_FILE,
      ),
    30000,
  )
})

describe("no prefix — gitpick <url> (without clone keyword)", () => {
  async function noPrefixClone(args: string[], expectedOutput: string, expectedTree: string) {
    const t = target()
    if (existsSync(t)) rmSync(t, { recursive: true, force: true })

    const { output, exitCode } = await run([...args, t])
    expect(parseLine(output)).toContain(expectedOutput)
    expect(exitCode).toBe(0)

    if (expectedTree === "(file)") {
      expect(lstatSync(t).isFile()).toBe(true)
    } else if (expectedTree) {
      expect(getTree(t)).toBe(expectedTree)
    } else {
      expect(existsSync(t)).toBe(true)
    }
  }

  it(
    "repo without clone prefix",
    () =>
      noPrefixClone(["nrjdalal/picksuite"], "nrjdalal/picksuite repository:main", TREE_REPO_MAIN),
    30000,
  )
  it(
    "tree without clone prefix",
    () =>
      noPrefixClone(
        ["nrjdalal/picksuite/tree/main/folder"],
        "nrjdalal/picksuite tree:main folder",
        TREE_FOLDER,
      ),
    30000,
  )
  it(
    "blob without clone prefix",
    () =>
      noPrefixClone(
        ["nrjdalal/picksuite/blob/main/file.txt"],
        "nrjdalal/picksuite blob:main file.txt",
        TREE_BLOB_FILE,
      ),
    30000,
  )
})

describe("target — gitpick <url> [target]", () => {
  it(
    "repo → custom dir",
    () =>
      cloneAndExpect(
        ["nrjdalal/picksuite"],
        "nrjdalal/picksuite repository:main",
        TREE_REPO_MAIN,
        "my-repo",
      ),
    30000,
  )
  it(
    "tree → custom dir",
    () =>
      cloneAndExpect(
        ["nrjdalal/picksuite/tree/main/folder"],
        "nrjdalal/picksuite tree:main folder",
        TREE_FOLDER,
        "my-folder",
      ),
    30000,
  )
  it(
    "blob → custom dir",
    () =>
      cloneAndExpect(
        ["nrjdalal/picksuite/blob/main/file.txt"],
        "nrjdalal/picksuite blob:main file.txt",
        TREE_BLOB_FILE,
        "my-blob",
      ),
    30000,
  )

  it(
    "repo → nested path",
    () =>
      cloneAndExpect(
        ["nrjdalal/picksuite"],
        "nrjdalal/picksuite repository:main",
        TREE_REPO_MAIN,
        "nested/path/repo",
      ),
    30000,
  )
  it(
    "tree → nested path",
    () =>
      cloneAndExpect(
        ["nrjdalal/picksuite/tree/main/folder"],
        "nrjdalal/picksuite tree:main folder",
        TREE_FOLDER,
        "nested/path/folder",
      ),
    30000,
  )
  it(
    "blob → nested path",
    () =>
      cloneAndExpect(
        ["nrjdalal/picksuite/blob/main/file.txt"],
        "nrjdalal/picksuite blob:main file.txt",
        TREE_BLOB_FILE,
        "nested/path/blob",
      ),
    30000,
  )

  it(
    "blob → renamed.txt",
    () =>
      cloneAndExpect(
        ["nrjdalal/picksuite/blob/main/file.txt"],
        "nrjdalal/picksuite blob:main file.txt",
        "(file)",
        "renamed.txt",
      ),
    30000,
  )
  it(
    "blob → nested renamed",
    () =>
      cloneAndExpect(
        ["nrjdalal/picksuite/blob/main/file.txt"],
        "nrjdalal/picksuite blob:main file.txt",
        "(file)",
        "some/path/renamed.txt",
      ),
    30000,
  )
})

describe("branch — gitpick <url> -b [branch/SHA]", () => {
  it(
    "-b dev shorthand",
    () =>
      cloneAndExpect(
        ["nrjdalal/picksuite", "-b", "dev"],
        "nrjdalal/picksuite repository:dev",
        TREE_REPO_DEV,
      ),
    30000,
  )
  it(
    "-b dev full URL",
    () =>
      cloneAndExpect(
        ["https://github.com/nrjdalal/picksuite", "-b", "dev"],
        "nrjdalal/picksuite repository:dev",
        TREE_REPO_DEV,
      ),
    30000,
  )
  it(
    "tree/dev",
    () =>
      cloneAndExpect(["nrjdalal/picksuite/tree/dev"], "nrjdalal/picksuite tree:dev", TREE_REPO_DEV),
    30000,
  )
  it(
    "tree/dev URL",
    () =>
      cloneAndExpect(
        ["https://github.com/nrjdalal/picksuite/tree/dev"],
        "nrjdalal/picksuite tree:dev",
        TREE_REPO_DEV,
      ),
    30000,
  )

  it(
    "-b short SHA",
    () =>
      cloneAndExpect(
        ["nrjdalal/picksuite", "-b", "8af536b"],
        "nrjdalal/picksuite repository:8af536b",
        TREE_REPO_MAIN,
      ),
    30000,
  )
  it(
    "/commit/ URL",
    () =>
      cloneAndExpect(
        ["https://github.com/nrjdalal/picksuite/commit/8af536b"],
        "nrjdalal/picksuite repository:8af536b",
        TREE_REPO_MAIN,
      ),
    30000,
  )
  it(
    "-b full SHA",
    () =>
      cloneAndExpect(
        ["nrjdalal/picksuite", "-b", "8af536b29d38630301fc47f2e088c41248d41932"],
        "nrjdalal/picksuite repository:8af536b29d38630301fc47f2e088c41248d41932",
        TREE_REPO_MAIN,
      ),
    30000,
  )

  it(
    "branch override",
    () =>
      cloneAndExpect(
        ["nrjdalal/picksuite/tree/dev/folder", "-b", "main"],
        "nrjdalal/picksuite tree:main folder",
        TREE_FOLDER,
      ),
    30000,
  )
  it(
    "branch override URL",
    () =>
      cloneAndExpect(
        ["https://github.com/nrjdalal/picksuite/tree/dev/folder", "-b", "main"],
        "nrjdalal/picksuite tree:main folder",
        TREE_FOLDER,
      ),
    30000,
  )

  it(
    "blob from dev branch",
    () =>
      cloneAndExpect(
        ["nrjdalal/picksuite/blob/dev/dev.txt"],
        "nrjdalal/picksuite blob:dev dev.txt",
        "",
      ),
    30000,
  )
})

describe("overwrite — gitpick <url> -o / -f", () => {
  it("-o re-clone", async () => {
    const t = target()
    await run(["clone", "nrjdalal/picksuite/tree/main/folder", "-o", t])
    const { output, exitCode } = await run([
      "clone",
      "nrjdalal/picksuite/tree/main/folder",
      "-o",
      t,
    ])
    expect(exitCode).toBe(0)
    expect(parseLine(output)).toContain("nrjdalal/picksuite tree:main folder")
    expect(getTree(t)).toBe(TREE_FOLDER)
  }, 60000)

  it("-f re-clone", async () => {
    const t = target()
    await run(["clone", "nrjdalal/picksuite/tree/main/folder", "-f", t])
    const { output, exitCode } = await run([
      "clone",
      "nrjdalal/picksuite/tree/main/folder",
      "-f",
      t,
    ])
    expect(exitCode).toBe(0)
    expect(parseLine(output)).toContain("nrjdalal/picksuite tree:main folder")
    expect(getTree(t)).toBe(TREE_FOLDER)
  }, 60000)
})

describe("overwrite rejection", () => {
  it("rejects non-empty dir without -o", async () => {
    const t = target()
    mkdirSync(t, { recursive: true })
    writeFileSync(join(t, "existing.txt"), "existing")

    const { output, exitCode } = await run(["clone", "nrjdalal/picksuite", t])
    expect(exitCode).toBe(1)
    expect(stripAnsi(output)).toContain("not empty")
  }, 30000)

  it("rejects existing blob without -o", async () => {
    const t = target()
    mkdirSync(t, { recursive: true })
    writeFileSync(join(t, "file.txt"), "existing")

    const { output, exitCode } = await run(["clone", "nrjdalal/picksuite/blob/main/file.txt", t])
    expect(exitCode).toBe(1)
    expect(stripAnsi(output)).toContain("target file exists")
  }, 30000)

  it("allows clone into empty dir", async () => {
    const t = target()
    mkdirSync(t, { recursive: true })

    const { exitCode } = await run(["clone", "nrjdalal/picksuite", t])
    expect(exitCode).toBe(0)
    expect(getTree(t)).toBe(TREE_REPO_MAIN)
  }, 30000)
})

describe("recursive — gitpick <url> -r", () => {
  it(
    "-r shorthand",
    () =>
      cloneAndExpect(
        ["nrjdalal/picksuite", "-r"],
        "nrjdalal/picksuite repository:main",
        TREE_REPO_MAIN,
      ),
    30000,
  )
  it(
    "-r full URL",
    () =>
      cloneAndExpect(
        ["https://github.com/nrjdalal/picksuite", "-r"],
        "nrjdalal/picksuite repository:main",
        TREE_REPO_MAIN,
      ),
    30000,
  )
})

describe("raw URL", () => {
  it(
    "raw.githubusercontent.com",
    () =>
      cloneAndExpect(
        ["https://raw.githubusercontent.com/nrjdalal/picksuite/refs/heads/main/file.txt"],
        "nrjdalal/picksuite raw:main file.txt",
        "(file)",
      ),
    30000,
  )
})

describe("token URL", () => {
  it(
    "public repo with token",
    () =>
      cloneAndExpect(
        ["https://fake_token@github.com/nrjdalal/picksuite"],
        "nrjdalal/picksuite repository:main",
        TREE_REPO_MAIN,
      ),
    30000,
  )
})

describe("gitlab", () => {
  it(
    "repo",
    () =>
      cloneAndExpect(
        ["https://gitlab.com/pages/plain-html", "-b", "main"],
        "pages/plain-html repository:main",
        TREE_GITLAB_REPO,
      ),
    30000,
  )
  it(
    "tree",
    () =>
      cloneAndExpect(
        ["https://gitlab.com/pages/plain-html/-/tree/main/public"],
        "pages/plain-html tree:main public",
        TREE_GITLAB_PUBLIC,
      ),
    30000,
  )
  it(
    "blob",
    () =>
      cloneAndExpect(
        ["https://gitlab.com/pages/plain-html/-/blob/main/README.md"],
        "pages/plain-html blob:main README.md",
        TREE_BLOB_README,
      ),
    30000,
  )
})

// =====================================================================
// CLI FLAGS
// =====================================================================

describe("CLI flags", () => {
  it("--version", async () => {
    const { output, exitCode } = await run(["--version"])
    expect(exitCode).toBe(0)
    expect(stripAnsi(output)).toContain("gitpick@")
  })

  it("--help (no args)", async () => {
    const { output, exitCode } = await run([])
    expect(exitCode).toBe(0)
    expect(stripAnsi(output)).toContain("clone specific directories or files")
  })

  it("--dry-run exits 0 without cloning", async () => {
    const { output, exitCode } = await run(["nrjdalal/picksuite", "--dry-run"])
    expect(exitCode).toBe(0)
    expect(parseLine(output)).toContain("nrjdalal/picksuite repository:main")
  }, 30000)
})

// =====================================================================
// INTEGRITY
// =====================================================================

describe("integrity — .git exclusion, symlinks, content", () => {
  // references first clone (test #1 in "default" section)
  const repoDir = join(ARTIFACTS, "cli", "1")

  it(".git excluded", () => {
    expect(existsSync(join(repoDir, ".git"))).toBe(false)
  })

  it("symlink.txt is a symlink", () => {
    expect(lstatSync(join(repoDir, "symlink.txt")).isSymbolicLink()).toBe(true)
  })

  it("symdir is a symlink", () => {
    expect(lstatSync(join(repoDir, "symdir")).isSymbolicLink()).toBe(true)
  })

  it("symlink.txt → file.txt", () => {
    expect(readlinkSync(join(repoDir, "symlink.txt"))).toBe("file.txt")
  })

  it("symdir → folder", () => {
    expect(readlinkSync(join(repoDir, "symdir"))).toBe("folder")
  })

  it("file.txt content", () => {
    expect(readFileSync(join(repoDir, "file.txt"), "utf-8").trim()).toBe("root file")
  })

  it("folder/nested.txt content", () => {
    expect(readFileSync(join(repoDir, "folder/nested.txt"), "utf-8").trim()).toBe("nested file")
  })

  it("folder/deep/file.txt content", () => {
    expect(readFileSync(join(repoDir, "folder/deep/file.txt"), "utf-8").trim()).toBe("deep file")
  })
})

// =====================================================================
// CONFIG FILE
// =====================================================================

describe("config — .gitpick.jsonc", () => {
  const configDir = join(ARTIFACTS, "config")

  const CONFIG_TREES: Record<number, string> = {
    1: TREE_REPO_MAIN,
    2: TREE_REPO_MAIN,
    3: TREE_REPO_MAIN,
    4: TREE_REPO_MAIN,
    5: TREE_FOLDER,
    6: TREE_FOLDER,
    7: TREE_FOLDER,
    8: TREE_FOLDER,
    9: TREE_BLOB_FILE,
    10: TREE_REPO_DEV,
    11: TREE_REPO_DEV,
    12: TREE_REPO_MAIN,
    13: TREE_FOLDER,
    14: TREE_REPO_MAIN,
    15: TREE_GITLAB_REPO,
    16: TREE_GITLAB_PUBLIC,
  }

  beforeAll(async () => {
    rmSync(configDir, { recursive: true, force: true })
    mkdirSync(configDir, { recursive: true })
    copyFileSync("tests/fixtures.jsonc", join(configDir, ".gitpick.jsonc"))

    // run gitpick with no args in the config dir to trigger config mode
    const { exitCode } = await run([], resolve(configDir))
    expect(exitCode).toBe(0)
  }, 600000)

  for (let i = 1; i <= 16; i++) {
    it(`entry #${i}`, () => {
      const dir = join(configDir, String(i))
      expect(existsSync(dir)).toBe(true)
      expect(readdirSync(dir).length).toBeGreaterThan(0)

      const expected = CONFIG_TREES[i]
      if (expected) {
        expect(getTree(dir)).toBe(expected)
      }
    })
  }
})

// =====================================================================
// TREE OUTPUT
// =====================================================================

describe("--tree output", () => {
  function parseTreeOutput(output: string) {
    const stripped = stripAnsi(output).trim()
    const lines = stripped.split("\n")
    return { header: lines[0], tree: lines.slice(1).join("\n") }
  }

  const fwd = (s: string) => s.replaceAll("\\", "/")

  it("clone tree shows header and tree", async () => {
    const t = target()
    const { output, exitCode } = await run([
      "clone",
      "nrjdalal/picksuite/tree/main/folder",
      t,
      "--tree",
    ])
    expect(exitCode).toBe(0)
    const { header, tree } = parseTreeOutput(output)
    expect(header).toContain(fwd(t))
    expect(tree).toBe(TREE_FOLDER)
  }, 30000)

  it("clone repo shows header and full tree", async () => {
    const t = target()
    const { output, exitCode } = await run(["clone", "nrjdalal/picksuite", t, "--tree"])
    expect(exitCode).toBe(0)
    const { header, tree } = parseTreeOutput(output)
    expect(header).toContain(fwd(t))
    expect(tree).toBe(TREE_REPO_MAIN)
  }, 30000)

  it("no human-readable output with --tree", async () => {
    const t = target()
    const { output } = await run(["clone", "nrjdalal/picksuite/tree/main/folder", t, "--tree"])
    expect(stripAnsi(output)).not.toContain("GitPick")
    expect(stripAnsi(output)).not.toContain("✔")
    expect(stripAnsi(output)).not.toContain("Picked")
  }, 30000)

  it("dry-run tree shows header and tree without leaving files", async () => {
    const t = target()
    const { output, exitCode } = await run([
      "nrjdalal/picksuite/tree/main/folder",
      t,
      "--dry-run",
      "--tree",
    ])
    expect(exitCode).toBe(0)
    const { header, tree } = parseTreeOutput(output)
    expect(header).toContain(fwd(t))
    expect(tree).toBe(TREE_FOLDER)
    expect(existsSync(resolve(t))).toBe(false)
  }, 30000)

  it("dry-run repo shows header and full tree", async () => {
    const t = target()
    const { output, exitCode } = await run(["nrjdalal/picksuite", t, "--dry-run", "--tree"])
    expect(exitCode).toBe(0)
    const { header, tree } = parseTreeOutput(output)
    expect(header).toContain(fwd(t))
    expect(tree).toBe(TREE_REPO_MAIN)
  }, 30000)

  it("header uses ./ for relative paths", async () => {
    const t = target()
    const { output, exitCode } = await run([
      "clone",
      "nrjdalal/picksuite/tree/main/folder",
      t,
      "--tree",
    ])
    expect(exitCode).toBe(0)
    const { header } = parseTreeOutput(output)
    expect(header.startsWith("./")).toBe(true)
  }, 30000)

  it("blob shows parent dir header and file node", async () => {
    const t = target()
    const { output, exitCode } = await run([
      "clone",
      "nrjdalal/picksuite/blob/main/file.txt",
      t,
      "--tree",
    ])
    expect(exitCode).toBe(0)
    const { header, tree } = parseTreeOutput(output)
    expect(header).toContain(fwd(join(ARTIFACTS, "cli")))
    expect(tree).toBe("└── file.txt")
  }, 30000)

  it("dry-run blob shows parent dir header and file node", async () => {
    const t = target()
    const { output, exitCode } = await run([
      "nrjdalal/picksuite/blob/main/file.txt",
      t,
      "--dry-run",
      "--tree",
    ])
    expect(exitCode).toBe(0)
    const { header, tree } = parseTreeOutput(output)
    expect(header).toContain(fwd(join(ARTIFACTS, "cli")))
    expect(tree).toBe("└── file.txt")
  }, 30000)
})

// =====================================================================
// QUIET & VERBOSE
// =====================================================================

describe("--quiet output", () => {
  it("suppresses all output on clone", async () => {
    const t = target()
    const { output, exitCode } = await run([
      "clone",
      "nrjdalal/picksuite/tree/main/folder",
      t,
      "-q",
    ])
    expect(exitCode).toBe(0)
    expect(output.trim()).toBe("")
  }, 30000)

  it("suppresses all output on dry-run", async () => {
    const { output, exitCode } = await run(["nrjdalal/picksuite", "--dry-run", "-q"])
    expect(exitCode).toBe(0)
    expect(output.trim()).toBe("")
  }, 30000)
})

describe("--verbose output", () => {
  it("shows clone metadata and stats on success", async () => {
    const t = target()
    const { output, exitCode } = await run([
      "clone",
      "nrjdalal/picksuite/tree/main/folder",
      t,
      "--verbose",
    ])
    expect(exitCode).toBe(0)
    const stripped = stripAnsi(output)
    expect(stripped).toContain("clone:")
    expect(stripped).toContain("shallow")
    expect(stripped).toContain("from:")
    expect(stripped).toContain("picksuite.git")
    expect(stripped).toContain("files:")
    expect(stripped).toContain("network:")
    expect(stripped).toContain("copy:")
    expect(stripped).toContain("total:")
    expect(stripped).toMatch(/\d+ B|\d+\.\d+ KB|\d+\.\d+ MB/)
  }, 30000)

  it("reports full clone strategy for SHA", async () => {
    const t = target()
    const { output, exitCode } = await run([
      "clone",
      "nrjdalal/picksuite",
      "-b",
      "8af536b",
      t,
      "--verbose",
    ])
    expect(exitCode).toBe(0)
    const stripped = stripAnsi(output)
    expect(stripped).toContain("full (depth=full)")
  }, 30000)
})

describe("--quiet / --verbose interactions", () => {
  it("--quiet with --tree shows tree only, no banner or spinner", async () => {
    const t = target()
    const { output, exitCode } = await run([
      "clone",
      "nrjdalal/picksuite/tree/main/folder",
      t,
      "-q",
      "--tree",
    ])
    expect(exitCode).toBe(0)
    const stripped = stripAnsi(output)
    // tree output still shows (--tree takes precedence for its own output)
    expect(stripped).not.toContain("GitPick")
    expect(stripped).not.toContain("Picked")
    expect(stripped).not.toContain("✔")
  }, 30000)

  it("--verbose with --tree shows tree but no verbose metadata", async () => {
    const t = target()
    const { output, exitCode } = await run([
      "clone",
      "nrjdalal/picksuite/tree/main/folder",
      t,
      "--verbose",
      "--tree",
    ])
    expect(exitCode).toBe(0)
    const stripped = stripAnsi(output)
    // --tree silences verbose output
    expect(stripped).not.toContain("clone:")
    expect(stripped).not.toContain("from:")
    // but tree still renders
    expect(stripped).toContain("deep")
    expect(stripped).toContain("nested.txt")
  }, 30000)

  it("--quiet dry-run produces no output", async () => {
    const { output, exitCode } = await run([
      "nrjdalal/picksuite/tree/main/folder",
      "--dry-run",
      "-q",
    ])
    expect(exitCode).toBe(0)
    expect(output.trim()).toBe("")
  }, 30000)

  it("--verbose dry-run shows info line but no clone metadata", async () => {
    const { output, exitCode } = await run(["nrjdalal/picksuite", "--dry-run", "--verbose"])
    expect(exitCode).toBe(0)
    const stripped = stripAnsi(output)
    expect(stripped).toContain("picksuite")
    // no clone metadata since nothing was cloned
    expect(stripped).not.toContain("clone:")
    expect(stripped).not.toContain("duration:")
  }, 30000)
})

// =====================================================================
// ENV VAR TOKENS
// =====================================================================

describe("env var token support", () => {
  it("GITHUB_TOKEN is used for shorthand URLs", async () => {
    const proc = Bun.spawn([...CLI, "nrjdalal/picksuite", "--dry-run"], {
      stdout: "pipe",
      stderr: "pipe",
      env: { ...process.env, GITHUB_TOKEN: "fake_github_token" },
    })
    const stdout = await new Response(proc.stdout).text()
    const exitCode = await proc.exited
    expect(exitCode).toBe(0)
    expect(parseLine(stdout)).toContain("nrjdalal/picksuite")
  }, 30000)

  it("GH_TOKEN fallback works", async () => {
    const proc = Bun.spawn([...CLI, "nrjdalal/picksuite", "--dry-run"], {
      stdout: "pipe",
      stderr: "pipe",
      env: { ...process.env, GITHUB_TOKEN: "", GH_TOKEN: "fake_gh_token" },
    })
    const stdout = await new Response(proc.stdout).text()
    const exitCode = await proc.exited
    expect(exitCode).toBe(0)
    expect(parseLine(stdout)).toContain("nrjdalal/picksuite")
  }, 30000)

  it("URL token takes precedence over env var", async () => {
    const proc = Bun.spawn(
      [...CLI, "https://fake_url_token@github.com/nrjdalal/picksuite", "--dry-run"],
      {
        stdout: "pipe",
        stderr: "pipe",
        env: { ...process.env, GITHUB_TOKEN: "fake_env_token" },
      },
    )
    const stdout = await new Response(proc.stdout).text()
    const exitCode = await proc.exited
    expect(exitCode).toBe(0)
    expect(parseLine(stdout)).toContain("nrjdalal/picksuite")
  }, 30000)
})

// =====================================================================
// NON-TTY SPINNER
// =====================================================================

describe("non-TTY spinner suppression", () => {
  it("no spinner frames in piped output", async () => {
    const t = target()
    const { output, exitCode } = await run(["clone", "nrjdalal/picksuite/tree/main/folder", t])
    expect(exitCode).toBe(0)
    const stripped = stripAnsi(output)
    // Bun.spawn captures stdout as pipe (non-TTY), so spinner should be suppressed
    for (const frame of ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]) {
      expect(stripped).not.toContain(frame)
    }
    // But success message should still appear
    expect(stripped).toContain("Picked")
  }, 30000)
})

// =====================================================================
// SIGINT CLEANUP
// =====================================================================

describe("SIGINT temp dir cleanup", () => {
  // SIGINT is a POSIX signal — Windows does not deliver it to child processes
  it.skipIf(process.platform === "win32")(
    "cleans up temp dir on SIGINT",
    async () => {
      const { readdirSync } = await import("node:fs")
      const { tmpdir } = await import("node:os")

      // Snapshot temp dirs before
      const before = new Set(readdirSync(tmpdir()).filter((d) => d.startsWith("picksuite-")))

      const proc = Bun.spawn(
        [...CLI, "clone", "nrjdalal/picksuite", "/tmp/gitpick-sigint-test", "-o"],
        { stdout: "pipe", stderr: "pipe" },
      )

      // Poll until a new temp dir appears (max 10s)
      let newTempDir: string | null = null
      for (let i = 0; i < 100; i++) {
        await new Promise((r) => setTimeout(r, 100))
        const current = readdirSync(tmpdir()).filter(
          (d) => d.startsWith("picksuite-") && !before.has(d),
        )
        if (current.length > 0) {
          newTempDir = current[0]
          break
        }
      }

      // If we found it, kill and verify cleanup
      if (newTempDir) {
        proc.kill("SIGINT")
        await proc.exited

        const after = readdirSync(tmpdir()).filter(
          (d) => d.startsWith("picksuite-") && !before.has(d),
        )
        expect(after).toHaveLength(0)
      } else {
        // Clone finished before we could catch the temp dir — still valid, just skip assertion
        await proc.exited
      }
    },
    30000,
  )
})

// ---------------------------------------------------------------------------
// Interactive mode
// ---------------------------------------------------------------------------
describe("interactive mode", () => {
  it("should error on non-TTY with -i flag", async () => {
    const { output, exitCode } = await run(["nrjdalal/gitpick", "-i", "-b", "main"])
    expect(exitCode).not.toBe(0)
    expect(stripAnsi(output)).toContain("Interactive mode requires a TTY")
  })

  it("should show -i in help text", async () => {
    const { output } = await run(["--help"])
    expect(stripAnsi(output)).toContain("-i, --interactive")
  })
})


================================================
FILE: tests/fixtures.jsonc
================================================
// Example config, run tests using `bun run test:config`
[
  // repo picks
  "nrjdalal/picksuite 1",
  "https://github.com/nrjdalal/picksuite 2",
  "git@github.com:nrjdalal/picksuite.git 3",
  "https://github.com/nrjdalal/picksuite.git 4",
  // tree picks
  "nrjdalal/picksuite/tree/main/folder 5",
  "https://github.com/nrjdalal/picksuite/tree/main/folder 6",
  "git@github.com:nrjdalal/picksuite.git/tree/main/folder 7",
  "https://github.com/nrjdalal/picksuite.git/tree/main/folder 8",
  // blob pick
  "nrjdalal/picksuite/blob/main/file.txt 9",
  // branch picks
  "nrjdalal/picksuite -b dev 10",
  "nrjdalal/picksuite/tree/dev 11",
  // commit SHA
  "nrjdalal/picksuite -b 8af536b 12",
  // branch override
  "nrjdalal/picksuite/tree/dev/folder -b main 13",
  // symlink support (full repo has symlinks)
  "nrjdalal/picksuite -r 14",
  // gitlab
  "https://gitlab.com/pages/plain-html -b main 15",
  "https://gitlab.com/pages/plain-html/-/tree/main/public 16",
]


================================================
FILE: tests/tree.mjs
================================================
import { readdirSync, readlinkSync, statSync } from "node:fs"
import { join } from "node:path"

function tree(dir, prefix = "") {
  const entries = readdirSync(dir, { withFileTypes: true }).filter((e) => e.name !== ".git")
  entries.forEach((e, i) => {
    const last = i === entries.length - 1
    const connector = last ? "└── " : "├── "
    if (e.isSymbolicLink()) {
      const target = readlinkSync(join(dir, e.name))
      console.log(`${prefix}${connector}${e.name} -> ${target}`)
    } else if (e.isDirectory()) {
      console.log(`${prefix}${connector}${e.name}`)
      tree(join(dir, e.name), `${prefix}${last ? "    " : "│   "}`)
    } else {
      console.log(`${prefix}${connector}${e.name}`)
    }
  })
}

const target = process.argv[2]
if (!target) process.exit(1)
const stat = statSync(target, { throwIfNoEntry: false })
if (stat?.isDirectory()) {
  console.log(target)
  tree(target)
} else {
  console.log(`${target} (file)`)
}


================================================
FILE: tsconfig.json
================================================
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "paths": {
      "~/*": ["./*"],
      "@/*": ["./bin/*"]
    }
  },
  "include": ["**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}


================================================
FILE: tsdown.config.ts
================================================
import { defineConfig } from "tsdown"

export default defineConfig({
  entry: ["bin/index.ts"],
  minify: true,
})


================================================
FILE: types/package.json.d.ts
================================================
declare module "~/package.json" {
  export const name: string
  export const version: string
  export const author: {
    name: string
    email: string
    url: string
  }
}
Download .txt
gitextract_ijhrzzvx/

├── .github/
│   ├── FUNDING.yml
│   └── workflows/
│       ├── dependencies.yml
│       ├── release.yml
│       └── test.yml
├── .gitignore
├── .lintstagedrc.json
├── .oxfmtrc.jsonc
├── .oxlintrc.jsonc
├── CHANGELOG.md
├── LICENSE
├── README.md
├── bin/
│   ├── external/
│   │   ├── nano-spawn.ts
│   │   ├── speed-highlight.ts
│   │   ├── strip-json-comments.ts
│   │   ├── yocto-spinner.ts
│   │   └── yoctocolors.ts
│   ├── index.ts
│   └── utils/
│       ├── clone-action.ts
│       ├── copy-dir.ts
│       ├── get-default-branch.ts
│       ├── interactive-picker.ts
│       ├── parse-time-string.ts
│       ├── transform-url.ts
│       ├── update-notifier.ts
│       └── use-config.ts
├── lefthook.yml
├── package.json
├── tests/
│   ├── cli.test.ts
│   ├── fixtures.jsonc
│   └── tree.mjs
├── tsconfig.json
├── tsdown.config.ts
└── types/
    └── package.json.d.ts
Download .txt
SYMBOL INDEX (63 symbols across 12 files)

FILE: bin/external/nano-spawn.ts
  class SubprocessError (line 10) | class SubprocessError extends Error {
  constant EXE_MEMO (line 19) | const EXE_MEMO: Record<string, Promise<boolean>> = {}
  function spawn (line 67) | async function spawn(

FILE: bin/external/speed-highlight.ts
  method exec (line 393) | exec(n) {
  function F (line 839) | async function F(n, t, p) {

FILE: bin/external/strip-json-comments.ts
  function stripJsonComments (line 18) | function stripJsonComments(jsonString: string) {

FILE: bin/external/yocto-spinner.ts
  method start (line 69) | start(t: string) {
  method success (line 79) | success(t: string) {

FILE: bin/index.ts
  function walkLocal (line 256) | async function walkLocal(dir: string, rel: string) {
  function walkDir (line 467) | async function walkDir(dir: string, rel: string) {

FILE: bin/utils/clone-action.ts
  function cleanupAndExit (line 12) | function cleanupAndExit() {
  type CloneResult (line 30) | type CloneResult = {

FILE: bin/utils/interactive-picker.ts
  constant EXT_TO_LANG (line 7) | const EXT_TO_LANG: Record<string, string> = {
  function detectLang (line 58) | function detectLang(filename: string): string {
  type TreeEntry (line 68) | type TreeEntry = {
  function truncateAnsi (line 77) | function truncateAnsi(s: string, maxWidth: number): string {
  type TreeNode (line 94) | type TreeNode = {
  function buildTree (line 106) | function buildTree(entries: TreeEntry[]): TreeNode[] {
  type FlatItem (line 199) | type FlatItem = {
  function flatten (line 205) | function flatten(roots: TreeNode[]): FlatItem[] {
  function setSelected (line 224) | function setSelected(node: TreeNode, value: boolean) {
  function resolveSymlinkPath (line 231) | function resolveSymlinkPath(symlinkPath: string, linkTarget: string): st...
  function findNodeByPath (line 244) | function findNodeByPath(roots: TreeNode[], targetPath: string): TreeNode...
  function updateParentSelection (line 255) | function updateParentSelection(roots: TreeNode[]) {
  function collectSelected (line 267) | function collectSelected(nodes: TreeNode[]): string[] {
  function countSelected (line 297) | function countSelected(nodes: TreeNode[]): {
  function interactivePicker (line 332) | function interactivePicker(

FILE: bin/utils/parse-time-string.ts
  function parseTimeString (line 1) | function parseTimeString(timeString: string | number): number {

FILE: bin/utils/transform-url.ts
  type Host (line 3) | type Host = "github.com" | "gitlab.com" | "bitbucket.org" | "codeberg.org"
  constant PREFIXES (line 5) | const PREFIXES: { prefix: string; host: Host }[] = [
  function configFromUrl (line 17) | async function configFromUrl(

FILE: bin/utils/update-notifier.ts
  constant CACHE_DIR (line 8) | const CACHE_DIR = path.join(os.homedir(), ".cache", "gitpick")
  constant CACHE_FILE (line 9) | const CACHE_FILE = path.join(CACHE_DIR, "update-check.json")
  constant CHECK_INTERVAL (line 10) | const CHECK_INTERVAL = 24 * 60 * 60 * 1000 // 24 hours
  type UpdateCache (line 12) | type UpdateCache = {
  function readCache (line 17) | function readCache(): UpdateCache | null {
  function writeCache (line 25) | function writeCache(cache: UpdateCache) {
  function fetchLatestVersion (line 32) | function fetchLatestVersion(): Promise<string | null> {
  function isNewer (line 61) | function isNewer(latest: string, current: string): boolean {
  function notifyUpdate (line 72) | function notifyUpdate(currentVersion: string, silent: boolean) {
  function scheduleUpdateCheck (line 86) | function scheduleUpdateCheck() {

FILE: tests/cli.test.ts
  constant CLI (line 15) | const CLI = ["node", resolve("dist/index.mjs")]
  constant ARTIFACTS (line 16) | const ARTIFACTS = ".test-artifacts"
  function stripAnsi (line 20) | function stripAnsi(s: string) {
  function run (line 25) | async function run(args: string[], cwd?: string) {
  function parseLine (line 38) | function parseLine(output: string) {
  function tree (line 46) | function tree(dir: string, prefix = ""): string {
  function getTree (line 66) | function getTree(dir: string) {
  constant TREE_REPO_MAIN (line 74) | const TREE_REPO_MAIN = [
  constant TREE_REPO_DEV (line 85) | const TREE_REPO_DEV = [
  constant TREE_FOLDER (line 97) | const TREE_FOLDER = ["├── deep", "│   └── file.txt", "└── nested.txt"].j...
  constant TREE_FOLDER_DEEP (line 98) | const TREE_FOLDER_DEEP = "└── file.txt"
  constant TREE_BLOB_FILE (line 99) | const TREE_BLOB_FILE = "└── file.txt"
  constant TREE_BLOB_NESTED (line 100) | const TREE_BLOB_NESTED = "└── nested.txt"
  constant TREE_BLOB_README (line 101) | const TREE_BLOB_README = "└── README.md"
  constant TREE_GITLAB_REPO (line 103) | const TREE_GITLAB_REPO = [
  constant TREE_GITLAB_PUBLIC (line 111) | const TREE_GITLAB_PUBLIC = ["├── index.html", "└── style.css"].join("\n")
  function target (line 116) | function target() {
  function cloneAndExpect (line 122) | async function cloneAndExpect(
  function dryRun (line 157) | async function dryRun(args: string[], expected: string) {
  function noPrefixClone (line 707) | async function noPrefixClone(args: string[], expectedOutput: string, exp...
  function parseTreeOutput (line 1210) | function parseTreeOutput(output: string) {

FILE: tests/tree.mjs
  function tree (line 4) | function tree(dir, prefix = "") {
Condensed preview — 33 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (190K chars).
[
  {
    "path": ".github/FUNDING.yml",
    "chars": 17,
    "preview": "github: nrjdalal\n"
  },
  {
    "path": ".github/workflows/dependencies.yml",
    "chars": 1224,
    "preview": "name: Update Dependencies\n\non:\n  schedule:\n    - cron: \"0 0 * * *\"\n\nconcurrency: ${{ github.workflow }}-${{ github.ref }"
  },
  {
    "path": ".github/workflows/release.yml",
    "chars": 5109,
    "preview": "name: Release Package\n\non:\n  push:\n    branches:\n      - \"**\"\n  issue_comment:\n    types: [created]\n\nconcurrency: ${{ gi"
  },
  {
    "path": ".github/workflows/test.yml",
    "chars": 490,
    "preview": "name: Test\n\non:\n  push:\n    branches:\n      - \"**\"\n\njobs:\n  test:\n    strategy:\n      matrix:\n        os: [ubuntu-latest"
  },
  {
    "path": ".gitignore",
    "chars": 186,
    "preview": "# os\n.DS_Store\n*.pem\n\n# logs\n/*-debug.log*\n/*-error.log*\n\n# lockfiles\nyarn.lock\npackage-lock.json\npnpm-lock.yaml\nbun.loc"
  },
  {
    "path": ".lintstagedrc.json",
    "chars": 63,
    "preview": "{\n  \"*\": [\"oxfmt --no-error-on-unmatched-pattern\", \"oxlint\"]\n}\n"
  },
  {
    "path": ".oxfmtrc.jsonc",
    "chars": 117,
    "preview": "{\n  \"$schema\": \"./node_modules/oxfmt/configuration_schema.json\",\n  \"semi\": false,\n  \"experimentalSortImports\": {},\n}\n"
  },
  {
    "path": ".oxlintrc.jsonc",
    "chars": 68,
    "preview": "{\n  \"$schema\": \"./node_modules/oxlint/configuration_schema.json\",\n}\n"
  },
  {
    "path": "CHANGELOG.md",
    "chars": 8437,
    "preview": "# Changelog\n\n## v5.4.0 (2026-03-22)\n\n- **Local directory interactive mode** - browse local directories with `gitpick -i`"
  },
  {
    "path": "LICENSE",
    "chars": 1069,
    "preview": "MIT License\n\nCopyright (c) 2025 Neeraj Dalal\n\nPermission is hereby granted, free of charge, to any person obtaining a co"
  },
  {
    "path": "README.md",
    "chars": 11380,
    "preview": "# GitPick\n\n<!--  -->\n\n**Clone exactly what you need aka straightforward project scaffolding!**\n\n[![Twitter](https://img."
  },
  {
    "path": "bin/external/nano-spawn.ts",
    "chars": 4539,
    "preview": "// Trimmed from nano-spawn by Sindre Sorhus (https://github.com/sindresorhus/nano-spawn)\nimport { spawn as nodeSpawn, ty"
  },
  {
    "path": "bin/external/speed-highlight.ts",
    "chars": 31196,
    "preview": "var te = Object.defineProperty\nvar d = (n) => (t) => {\n  var p = n[t]\n  if (p) return p()\n  throw new Error(\"Module not "
  },
  {
    "path": "bin/external/strip-json-comments.ts",
    "chars": 3433,
    "preview": "// From strip-json-comments by Sindre Sorhus (https://github.com/sindresorhus/strip-json-comments)\nconst singleComment ="
  },
  {
    "path": "bin/external/yocto-spinner.ts",
    "chars": 2534,
    "preview": "// Trimmed from yocto-spinner by Sindre Sorhus (https://github.com/sindresorhus/yocto-spinner)\nimport process from \"node"
  },
  {
    "path": "bin/external/yoctocolors.ts",
    "chars": 1198,
    "preview": "// Trimmed from yoctocolors by Sindre Sorhus (https://github.com/sindresorhus/yoctocolors)\nimport tty from \"node:tty\"\n\nc"
  },
  {
    "path": "bin/index.ts",
    "chars": 22549,
    "preview": "#!/usr/bin/env node\nimport fs from \"node:fs\"\nimport os from \"node:os\"\nimport path from \"node:path\"\nimport { parseArgs } "
  },
  {
    "path": "bin/utils/clone-action.ts",
    "chars": 4656,
    "preview": "import fs from \"node:fs\"\nimport os from \"node:os\"\nimport path from \"node:path\"\n\nimport spawn from \"@/external/nano-spawn"
  },
  {
    "path": "bin/utils/copy-dir.ts",
    "chars": 954,
    "preview": "import fs from \"node:fs\"\nimport path from \"node:path\"\n\nexport const copyDir = async (\n  src: string,\n  dest: string,\n  r"
  },
  {
    "path": "bin/utils/get-default-branch.ts",
    "chars": 399,
    "preview": "import spawn from \"@/external/nano-spawn\"\n\nexport const getDefaultBranch = async (url: string) => {\n  const remotes = (a"
  },
  {
    "path": "bin/utils/interactive-picker.ts",
    "chars": 23369,
    "preview": "import fs from \"node:fs\"\nimport path from \"node:path\"\n\nimport { highlightText } from \"@/external/speed-highlight\"\nimport"
  },
  {
    "path": "bin/utils/parse-time-string.ts",
    "chars": 744,
    "preview": "export function parseTimeString(timeString: string | number): number {\n  if (typeof timeString === \"number\" || /^\\d+$/.t"
  },
  {
    "path": "bin/utils/transform-url.ts",
    "chars": 5107,
    "preview": "import { getDefaultBranch } from \"@/utils/get-default-branch\"\n\ntype Host = \"github.com\" | \"gitlab.com\" | \"bitbucket.org\""
  },
  {
    "path": "bin/utils/update-notifier.ts",
    "chars": 2619,
    "preview": "import fs from \"node:fs\"\nimport https from \"node:https\"\nimport os from \"node:os\"\nimport path from \"node:path\"\n\nimport { "
  },
  {
    "path": "bin/utils/use-config.ts",
    "chars": 965,
    "preview": "import fs from \"node:fs\"\nimport path from \"node:path\"\n\nimport spawn from \"@/external/nano-spawn\"\nimport stripJsonComment"
  },
  {
    "path": "lefthook.yml",
    "chars": 196,
    "preview": "pre-commit:\n  piped: true\n  commands:\n    lint-staged:\n      run: bunx lint-staged --verbose\n      stage_fixed: true\n\nco"
  },
  {
    "path": "package.json",
    "chars": 1429,
    "preview": "{\n  \"name\": \"gitpick\",\n  \"version\": \"5.4.1\",\n  \"description\": \"Clone exactly what you need aka straightforward project s"
  },
  {
    "path": "tests/cli.test.ts",
    "chars": 40600,
    "preview": "import { beforeAll, describe, expect, it } from \"bun:test\"\nimport {\n  copyFileSync,\n  existsSync,\n  lstatSync,\n  mkdirSy"
  },
  {
    "path": "tests/fixtures.jsonc",
    "chars": 968,
    "preview": "// Example config, run tests using `bun run test:config`\n[\n  // repo picks\n  \"nrjdalal/picksuite 1\",\n  \"https://github.c"
  },
  {
    "path": "tests/tree.mjs",
    "chars": 947,
    "preview": "import { readdirSync, readlinkSync, statSync } from \"node:fs\"\nimport { join } from \"node:path\"\n\nfunction tree(dir, prefi"
  },
  {
    "path": "tsconfig.json",
    "chars": 550,
    "preview": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"compilerOptions\": {\n    \"target\": \"ES2017\",\n    \"lib\": [\"dom\""
  },
  {
    "path": "tsdown.config.ts",
    "chars": 115,
    "preview": "import { defineConfig } from \"tsdown\"\n\nexport default defineConfig({\n  entry: [\"bin/index.ts\"],\n  minify: true,\n})\n"
  },
  {
    "path": "types/package.json.d.ts",
    "chars": 175,
    "preview": "declare module \"~/package.json\" {\n  export const name: string\n  export const version: string\n  export const author: {\n  "
  }
]

About this extraction

This page contains the full source code of the nrjdalal/gitpick GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 33 files (173.2 KB), approximately 51.9k tokens, and a symbol index with 63 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!