diff --git a/.changeset/initial-release.md b/.changeset/initial-release.md
deleted file mode 100644
index f6ad5ac..0000000
--- a/.changeset/initial-release.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"venfork": patch
----
-
-Initial release of venfork - Create and manage private mirrors for vendor development workflows. Includes setup, sync, stage, and status commands.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e015a27..8daf7ca 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Setup Bun
uses: oven-sh/setup-bun@v1
@@ -64,7 +64,7 @@ jobs:
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v4
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 73116f6..d58b0ce 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,44 @@
# venfork
+## 0.2.0
+
+### Minor Changes
+
+- [`61a2e54`](https://github.com/cabljac/venfork/commit/61a2e5454dbb04d089898ab2837e0f247954c3f8) Thanks [@cabljac](https://github.com/cabljac)! - Performance improvements and bug fixes:
+
+ - **Faster setup**: Changed from full repository mirror to cloning only the default branch, dramatically reducing setup time for large repositories
+ - **Fixed argument parsing**: Now supports both `--org value` and `--org=value` formats for the organization flag
+
+## 0.1.1
+
+### Patch Changes
+
+- Update README documentation to reflect v0.1.0 changes - update all examples from `-vendor` to `-private` suffix and document new venfork-config branch feature
+
+## 0.1.0
+
+### Minor Changes
+
+- **New Features:**
+
+ - Add venfork-config branch for reliable clone detection - stores publicForkUrl and upstreamUrl in `.venfork/config.json` on a dedicated orphan branch
+ - Clone command now reads config first, falling back to auto-detection if not found
+
+ **Improvements:**
+
+ - Change default suffix from `-vendor` to `-private` for private mirror repositories
+ - Fix personal account handling - passing `--org` with your personal username now works correctly
+ - Improve setup prompts to only ask for missing values instead of always prompting
+
+ **Bug Fixes:**
+
+ - Fix GitHub API error when using personal account with `--org` flag
+ - Fix redundant upstream URL prompt when provided as command argument
+
+### Patch Changes
+
+- [`86a246b`](https://github.com/cabljac/venfork/commit/86a246b5bf9c39463854c45d514d3e8edce6df38) Thanks [@cabljac](https://github.com/cabljac)! - Initial release of venfork - Create and manage private mirrors for vendor development workflows. Includes setup, sync, stage, and status commands.
+
## 0.0.1
### Patch Changes
diff --git a/README.md b/README.md
index 33dd8ad..2849568 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,7 @@
+
+
+
+
# 🔧 Venfork
[](https://github.com/cabljac/venfork/actions/workflows/ci.yml)
@@ -9,19 +13,21 @@ Create and manage private mirrors of public GitHub repositories for vendor devel
Venfork helps contractors and vendors who need to work on private forks of public repositories. It creates a **three-repository workflow**:
-1. **Private Mirror** (`yourname/project-vendor`) - Where your team works internally
-2. **Public Fork** (`yourname/project`) - Staging area for contributions to upstream
+1. **Private Mirror** (`yourname/project-private` or `org/project-private`) - Where your team works internally
+2. **Public Fork** (`yourname/project` or `org/project`) - Staging area for contributions to upstream
3. **Upstream** (`original/project`) - The original repository
+> **Note:** Repos can be created under your personal account or under an organization using the `--org` flag.
+
### Why Three Repositories?
**The Key Insight:**
-> "Because the private fork is not attached to the public repo, our juniors can work on it and learn there without being seen by our client"
+> "The private mirror is completely disconnected from the public fork, allowing teams to experiment freely before presenting work to the client"
The private mirror is:
- ✅ Completely disconnected from the public fork
-- ✅ Safe for junior devs to learn, make mistakes, iterate
-- ✅ All internal PRs, reviews, experiments stay private
+- ✅ Safe space to experiment, iterate, and refine work
+- ✅ All internal PRs, reviews, and experiments stay private
- ✅ Only visible to your team
When you run `venfork stage`, your work becomes visible on the public fork and ready for PR to upstream.
@@ -57,14 +63,20 @@ npx venfork setup
## Quick Start
```bash
-# 1. One-time setup
+# 1a. One-time setup (first team member, personal account)
venfork setup git@github.com:awesome/project.git
-cd project-vendor
+# Or for organization repos
+venfork setup git@github.com:awesome/project.git --org my-company
+
+# 1b. Clone existing setup (other team members)
+venfork clone git@github.com:yourname/project-private.git
+
+cd project-private
# 2. Work privately
git checkout -b feature/new-thing
-# ... make changes, learn, iterate ...
+# ... experiment, iterate, refine ...
git push origin feature/new-thing
# Still private! Create internal PR for team review
@@ -76,13 +88,14 @@ venfork stage feature/new-thing
## Commands
-### `venfork setup [name]`
+### `venfork setup [name] [--org ]`
Creates the complete vendor workflow setup:
**What it creates:**
-- **Private mirror** (`yourname/project-vendor`) - For internal work
-- **Public fork** (`yourname/project`) - For staging to upstream
+- **Private mirror** (`yourname/project-private` or `org/project-private`) - For internal work
+- **Public fork** (`yourname/project` or `org/project`) - For staging to upstream
+- **Config branch** (`venfork-config`) - Stores remote URLs for easy team cloning
- **Local clone** with three remotes configured:
- `origin` → private mirror (default push/pull)
- `public` → public fork (for staging)
@@ -90,34 +103,87 @@ Creates the complete vendor workflow setup:
**Arguments:**
- `upstream-url` - GitHub repository URL (SSH or HTTPS)
-- `name` - (Optional) Name for private vendor repo (default: `{repo}-vendor`)
+- `name` - (Optional) Name for private mirror repo (default: `{repo}-private`)
+- `--org ` - (Optional) Create repos under organization instead of personal account
**Examples:**
```bash
+# Personal account (default)
venfork setup git@github.com:vercel/next.js.git
-# Creates: next.js-vendor (private), next.js (public fork)
+# Creates: yourname/next.js-private (private), yourname/next.js (public fork)
venfork setup https://github.com/vuejs/vue.git vue-internal
-# Creates: vue-internal (private), vue (public fork)
+# Creates: yourname/vue-internal (private), yourname/vue (public fork)
+
+# Organization account
+venfork setup git@github.com:client/awesome-project.git --org acme-corp
+# Creates: acme-corp/awesome-project-private (private), acme-corp/awesome-project (public fork)
+
+venfork setup git@github.com:client/project.git internal-mirror --org my-company
+# Creates: my-company/internal-mirror (private), my-company/project (public fork)
```
+### `venfork clone `
+
+Clone an existing vendor setup and automatically configure all remotes.
+
+**What it does:**
+- Clones the private mirror repository
+- **Reads venfork-config branch** for public fork and upstream URLs (if available)
+- Falls back to auto-detection:
+ - Public fork (by stripping `-private` suffix)
+ - Upstream repository (from public fork's parent)
+- Configures all three remotes (origin, public, upstream)
+- Disables push to upstream (read-only)
+
+**Use this when:**
+- A teammate has already run `venfork setup`
+- You need to clone an existing vendor setup
+- You want automatic remote configuration
+
+**Arguments:**
+- `vendor-repo-url` - GitHub URL of the private vendor repository (SSH or HTTPS)
+
+**Examples:**
+```bash
+# Clone existing vendor setup (personal account)
+venfork clone git@github.com:yourname/project-private.git
+# Reads config from venfork-config branch (if available)
+# Or auto-detects: public fork at yourname/project
+
+# Clone organization vendor setup
+venfork clone git@github.com:acme-corp/awesome-project-private.git
+# Reads config from venfork-config branch (if available)
+# Or auto-detects: public fork at acme-corp/awesome-project
+```
+
+**Interactive prompts:**
+- If public fork cannot be auto-detected, you'll be prompted for the URL
+- If upstream cannot be auto-detected (no parent), you'll be prompted for the URL
+
### `venfork sync [branch]`
-Fetch from upstream and rebase current branch to stay up-to-date.
+Update the default branches of your private mirror and public fork to match upstream.
**Arguments:**
-- `branch` - (Optional) Upstream branch to sync with (default: `main`)
+- `branch` - (Optional) Upstream branch to sync (default: auto-detected, usually `main` or `master`)
**Examples:**
```bash
-venfork sync # Sync with upstream/main
-venfork sync develop # Sync with upstream/develop
+venfork sync # Sync default branches with upstream
+venfork sync develop # Sync develop branch with upstream/develop
```
**What it does:**
-1. Fetches latest changes from upstream
-2. Rebases your current branch on upstream
-3. Handles conflicts gracefully with instructions
+1. Fetches latest changes from all remotes (upstream, origin, public)
+2. Checks for divergent commits (warns if found to prevent data loss)
+3. Force pushes upstream's default branch to origin and public
+4. **Does not affect your current working branch or feature branches**
+
+**Important:**
+- This keeps your default branches (main/master) in sync with upstream
+- Your current work on feature branches is completely unaffected
+- If divergent commits are detected, sync will abort to prevent data loss
### `venfork status`
@@ -161,31 +227,79 @@ venfork stage bugfix/issue-123
3. Pushes to public fork
4. Provides PR creation link
+## Environment Variables
+
+### `VENFORK_ORG`
+
+Set a default organization for all venfork commands. This avoids having to type `--org` every time.
+
+**Priority order:**
+1. `--org` flag (highest priority - always overrides)
+2. `VENFORK_ORG` environment variable
+3. Personal account (prompts for confirmation)
+
+**Usage:**
+
+```bash
+# Set in your shell profile (~/.zshrc, ~/.bashrc, etc.)
+export VENFORK_ORG=my-company
+
+# Now all commands use this org by default
+venfork setup git@github.com:client/project.git
+# Creates: my-company/project-vendor (private), my-company/project (public fork)
+
+# Override with --org flag when needed
+venfork setup git@github.com:other-client/app.git --org different-org
+# Creates: different-org/app-vendor (private), different-org/app (public fork)
+```
+
+**Safety feature:**
+If neither `--org` nor `VENFORK_ORG` is set, venfork will prompt for confirmation before creating repos under your personal account. This prevents accidental personal repo creation when working as a vendor/contractor.
+
+```bash
+# Without VENFORK_ORG or --org
+venfork setup git@github.com:client/project.git
+
+# Output:
+# ⚠️ No organization specified
+# Repos will be created under your personal account (username: yourname)
+# Continue with personal account? (y/N)
+```
+
## Complete Workflow
### Initial Setup
```bash
-# Clone and configure the repos
+# Clone and configure the repos (personal account)
venfork setup git@github.com:client/awesome-project.git
+# Or for organization
+venfork setup git@github.com:client/awesome-project.git --org acme-corp
+
# Navigate to private mirror
-cd awesome-project-vendor
+cd awesome-project-private
# Check setup status
venfork status
# Or verify remotes manually
git remote -v
-# origin git@github.com:you/awesome-project-vendor.git (private)
+# With personal account:
+# origin git@github.com:you/awesome-project-private.git (private)
# public git@github.com:you/awesome-project.git (public fork)
# upstream git@github.com:client/awesome-project.git (read-only)
+
+# With organization:
+# origin git@github.com:acme-corp/awesome-project-private.git (private)
+# public git@github.com:acme-corp/awesome-project.git (public fork)
+# upstream git@github.com:client/awesome-project.git (read-only)
```
### Daily Development
```bash
-# Sync with upstream before starting
+# Sync default branches with upstream (optional, keeps main up-to-date)
venfork sync
# Create feature branch
@@ -206,7 +320,7 @@ git push origin feature/user-auth
```bash
# Team reviews PR in private repo
-# Junior devs iterate, learn, make mistakes
+# Experiment, iterate, refine approach
# All feedback and changes stay private
# Once approved internally, merge to main
@@ -241,7 +355,7 @@ venfork stage feature/user-auth
│ fork
▼
┌─────────────────────────────────────────────┐
-│ Public Fork (you/project) │
+│ Public Fork (you/project or org/project) │
│ • Visible to everyone │
│ • Staging area for PRs │
│ • Only pushed to via `venfork stage` │
@@ -250,11 +364,13 @@ venfork stage feature/user-auth
│ mirror (disconnected)
▼
┌─────────────────────────────────────────────┐
-│ Private Mirror (you/project-vendor) │
+│ Private Mirror (you/project-private) │
+│ (or org/project-private) │
│ • Only visible to your team │
-│ • Where juniors learn & iterate │
+│ • Safe space to experiment & iterate │
│ • Internal PRs and reviews │
│ • Your daily work happens here │
+│ • Contains venfork-config branch │
└─────────────────────────────────────────────┘
```
@@ -266,15 +382,17 @@ After `venfork setup`, your local repository has three remotes:
| Remote | URL | Purpose |
|--------|-----|---------|
-| `origin` | `you/project-vendor` | Private work (default) |
-| `public` | `you/project` | Stage for upstream |
+| `origin` | `you/project-private` (or `org/project-private`) | Private work (default) |
+| `public` | `you/project` (or `org/project`) | Stage for upstream |
| `upstream` | `original/project` | Sync with latest |
+**Note:** When using `--org`, all repos are created under the specified organization.
+
### Default Behavior
- `git push` → Pushes to `origin` (private mirror)
- `git pull` → Pulls from `origin` (private mirror)
-- `venfork sync` → Fetches from `upstream`
+- `venfork sync` → Updates default branches of `origin` and `public` to match `upstream`
- `venfork stage` → Pushes to `public`
## Troubleshooting
@@ -300,13 +418,13 @@ This means `venfork setup` wasn't run or didn't complete successfully.
- Run `venfork status` to see which remotes are missing
- Re-run `venfork setup` if needed
-### Rebase Conflicts
+### Divergent Commits Warning
-When `venfork sync` encounters conflicts:
-1. Open the conflicted files and resolve markers
-2. Stage the resolved files: `git add `
-3. Continue: `git rebase --continue`
-4. Or abort: `git rebase --abort`
+If `venfork sync` detects commits on your default branch that aren't in upstream:
+1. This suggests work was committed directly to main/master (not recommended)
+2. Sync will abort to prevent losing these commits
+3. To preserve: manually rebase or cherry-pick them to a feature branch
+4. To force sync anyway: `git push origin upstream/main:main -f` (loses commits)
### Branch Already Exists on Public Fork
diff --git a/assets/logo.svg b/assets/logo.svg
new file mode 100644
index 0000000..6b8d6ed
--- /dev/null
+++ b/assets/logo.svg
@@ -0,0 +1,6 @@
+
+
\ No newline at end of file
diff --git a/bun.lock b/bun.lock
index 642aeb1..da1ee00 100644
--- a/bun.lock
+++ b/bun.lock
@@ -9,12 +9,16 @@
},
"devDependencies": {
"@biomejs/biome": "latest",
+ "@changesets/changelog-github": "^0.5.0",
+ "@changesets/cli": "^2.27.1",
"@types/bun": "latest",
"typescript": "^5.7.3",
},
},
},
"packages": {
+ "@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
+
"@biomejs/biome": ["@biomejs/biome@2.2.5", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.5", "@biomejs/cli-darwin-x64": "2.2.5", "@biomejs/cli-linux-arm64": "2.2.5", "@biomejs/cli-linux-arm64-musl": "2.2.5", "@biomejs/cli-linux-x64": "2.2.5", "@biomejs/cli-linux-x64-musl": "2.2.5", "@biomejs/cli-win32-arm64": "2.2.5", "@biomejs/cli-win32-x64": "2.2.5" }, "bin": { "biome": "bin/biome" } }, "sha512-zcIi+163Rc3HtyHbEO7CjeHq8DjQRs40HsGbW6vx2WI0tg8mYQOPouhvHSyEnCBAorfYNnKdR64/IxO7xQ5faw=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MYT+nZ38wEIWVcL5xLyOhYQQ7nlWD0b/4mgATW2c8dvq7R4OQjt/XGXFkXrmtWmQofaIM14L7V8qIz/M+bx5QQ=="],
@@ -33,52 +37,220 @@
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.5", "", { "os": "win32", "cpu": "x64" }, "sha512-F/jhuXCssPFAuciMhHKk00xnCAxJRS/pUzVfXYmOMUp//XW7mO6QeCjsjvnm8L4AO/dG2VOB0O+fJPiJ2uXtIw=="],
+ "@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.0.13", "", { "dependencies": { "@changesets/config": "^3.1.1", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "detect-indent": "^6.0.0", "fs-extra": "^7.0.1", "lodash.startcase": "^4.4.0", "outdent": "^0.5.0", "prettier": "^2.7.1", "resolve-from": "^5.0.0", "semver": "^7.5.3" } }, "sha512-BIW7bofD2yAWoE8H4V40FikC+1nNFEKBisMECccS16W1rt6qqhNTBDmIw5HaqmMgtLNz9e7oiALiEUuKrQ4oHg=="],
+
+ "@changesets/assemble-release-plan": ["@changesets/assemble-release-plan@6.0.9", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "semver": "^7.5.3" } }, "sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ=="],
+
+ "@changesets/changelog-git": ["@changesets/changelog-git@0.2.1", "", { "dependencies": { "@changesets/types": "^6.1.0" } }, "sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q=="],
+
+ "@changesets/changelog-github": ["@changesets/changelog-github@0.5.1", "", { "dependencies": { "@changesets/get-github-info": "^0.6.0", "@changesets/types": "^6.1.0", "dotenv": "^8.1.0" } }, "sha512-BVuHtF+hrhUScSoHnJwTELB4/INQxVFc+P/Qdt20BLiBFIHFJDDUaGsZw+8fQeJTRP5hJZrzpt3oZWh0G19rAQ=="],
+
+ "@changesets/cli": ["@changesets/cli@2.29.7", "", { "dependencies": { "@changesets/apply-release-plan": "^7.0.13", "@changesets/assemble-release-plan": "^6.0.9", "@changesets/changelog-git": "^0.2.1", "@changesets/config": "^3.1.1", "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/get-release-plan": "^4.0.13", "@changesets/git": "^3.0.4", "@changesets/logger": "^0.1.1", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.5", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@changesets/write": "^0.4.0", "@inquirer/external-editor": "^1.0.0", "@manypkg/get-packages": "^1.1.3", "ansi-colors": "^4.1.3", "ci-info": "^3.7.0", "enquirer": "^2.4.1", "fs-extra": "^7.0.1", "mri": "^1.2.0", "p-limit": "^2.2.0", "package-manager-detector": "^0.2.0", "picocolors": "^1.1.0", "resolve-from": "^5.0.0", "semver": "^7.5.3", "spawndamnit": "^3.0.1", "term-size": "^2.1.0" }, "bin": { "changeset": "bin.js" } }, "sha512-R7RqWoaksyyKXbKXBTbT4REdy22yH81mcFK6sWtqSanxUCbUi9Uf+6aqxZtDQouIqPdem2W56CdxXgsxdq7FLQ=="],
+
+ "@changesets/config": ["@changesets/config@3.1.1", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/logger": "^0.1.1", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "fs-extra": "^7.0.1", "micromatch": "^4.0.8" } }, "sha512-bd+3Ap2TKXxljCggI0mKPfzCQKeV/TU4yO2h2C6vAihIo8tzseAn2e7klSuiyYYXvgu53zMN1OeYMIQkaQoWnA=="],
+
+ "@changesets/errors": ["@changesets/errors@0.2.0", "", { "dependencies": { "extendable-error": "^0.1.5" } }, "sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow=="],
+
+ "@changesets/get-dependents-graph": ["@changesets/get-dependents-graph@2.1.3", "", { "dependencies": { "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "picocolors": "^1.1.0", "semver": "^7.5.3" } }, "sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ=="],
+
+ "@changesets/get-github-info": ["@changesets/get-github-info@0.6.0", "", { "dependencies": { "dataloader": "^1.4.0", "node-fetch": "^2.5.0" } }, "sha512-v/TSnFVXI8vzX9/w3DU2Ol+UlTZcu3m0kXTjTT4KlAdwSvwutcByYwyYn9hwerPWfPkT2JfpoX0KgvCEi8Q/SA=="],
+
+ "@changesets/get-release-plan": ["@changesets/get-release-plan@4.0.13", "", { "dependencies": { "@changesets/assemble-release-plan": "^6.0.9", "@changesets/config": "^3.1.1", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.5", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3" } }, "sha512-DWG1pus72FcNeXkM12tx+xtExyH/c9I1z+2aXlObH3i9YA7+WZEVaiHzHl03thpvAgWTRaH64MpfHxozfF7Dvg=="],
+
+ "@changesets/get-version-range-type": ["@changesets/get-version-range-type@0.4.0", "", {}, "sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ=="],
+
+ "@changesets/git": ["@changesets/git@3.0.4", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@manypkg/get-packages": "^1.1.3", "is-subdir": "^1.1.1", "micromatch": "^4.0.8", "spawndamnit": "^3.0.1" } }, "sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw=="],
+
+ "@changesets/logger": ["@changesets/logger@0.1.1", "", { "dependencies": { "picocolors": "^1.1.0" } }, "sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg=="],
+
+ "@changesets/parse": ["@changesets/parse@0.4.1", "", { "dependencies": { "@changesets/types": "^6.1.0", "js-yaml": "^3.13.1" } }, "sha512-iwksMs5Bf/wUItfcg+OXrEpravm5rEd9Bf4oyIPL4kVTmJQ7PNDSd6MDYkpSJR1pn7tz/k8Zf2DhTCqX08Ou+Q=="],
+
+ "@changesets/pre": ["@changesets/pre@2.0.2", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "fs-extra": "^7.0.1" } }, "sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug=="],
+
+ "@changesets/read": ["@changesets/read@0.6.5", "", { "dependencies": { "@changesets/git": "^3.0.4", "@changesets/logger": "^0.1.1", "@changesets/parse": "^0.4.1", "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "p-filter": "^2.1.0", "picocolors": "^1.1.0" } }, "sha512-UPzNGhsSjHD3Veb0xO/MwvasGe8eMyNrR/sT9gR8Q3DhOQZirgKhhXv/8hVsI0QpPjR004Z9iFxoJU6in3uGMg=="],
+
+ "@changesets/should-skip-package": ["@changesets/should-skip-package@0.1.2", "", { "dependencies": { "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3" } }, "sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw=="],
+
+ "@changesets/types": ["@changesets/types@6.1.0", "", {}, "sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA=="],
+
+ "@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "human-id": "^4.1.1", "prettier": "^2.7.1" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="],
+
"@clack/core": ["@clack/core@0.5.0", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow=="],
"@clack/prompts": ["@clack/prompts@0.11.0", "", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="],
+ "@inquirer/external-editor": ["@inquirer/external-editor@1.0.2", "", { "dependencies": { "chardet": "^2.1.0", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ=="],
+
+ "@manypkg/find-root": ["@manypkg/find-root@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@types/node": "^12.7.1", "find-up": "^4.1.0", "fs-extra": "^8.1.0" } }, "sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA=="],
+
+ "@manypkg/get-packages": ["@manypkg/get-packages@1.1.3", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@changesets/types": "^4.0.1", "@manypkg/find-root": "^1.1.0", "fs-extra": "^8.1.0", "globby": "^11.0.0", "read-yaml-file": "^1.1.0" } }, "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A=="],
+
+ "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
+
+ "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
+
+ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
+
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
- "@types/bun": ["@types/bun@1.2.23", "", { "dependencies": { "bun-types": "1.2.23" } }, "sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A=="],
+ "@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="],
"@types/node": ["@types/node@24.7.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-CmyhGZanP88uuC5GpWU9q+fI61j2SkhO3UGMUdfYRE6Bcy0ccyzn1Rqj9YAB/ZY4kOXmNf0ocah5GtphmLMP6Q=="],
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
- "bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw=="],
+ "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
+
+ "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+
+ "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
+
+ "array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="],
+
+ "better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="],
+
+ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
+
+ "bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="],
+
+ "chardet": ["chardet@2.1.0", "", {}, "sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA=="],
+
+ "ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
+ "dataloader": ["dataloader@1.4.0", "", {}, "sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw=="],
+
+ "detect-indent": ["detect-indent@6.1.0", "", {}, "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA=="],
+
+ "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="],
+
+ "dotenv": ["dotenv@8.6.0", "", {}, "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g=="],
+
+ "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="],
+
+ "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
+
"execa": ["execa@9.6.0", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw=="],
+ "extendable-error": ["extendable-error@0.1.7", "", {}, "sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg=="],
+
+ "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
+
+ "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
+
"figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
+ "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
+
+ "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
+
+ "fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="],
+
"get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
+ "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
+
+ "globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="],
+
+ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
+
+ "human-id": ["human-id@4.1.2", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-v/J+4Z/1eIJovEBdlV5TYj1IR+ZiohcYGRY+qN/oC9dAfKzVT023N/Bgw37hrKCoVRBvk3bqyzpr2PP5YeTMSg=="],
+
"human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
+ "iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="],
+
+ "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
+
+ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
+
+ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
+
+ "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
+
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
+ "is-subdir": ["is-subdir@1.2.0", "", { "dependencies": { "better-path-resolve": "1.0.0" } }, "sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw=="],
+
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
+ "is-windows": ["is-windows@1.0.2", "", {}, "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA=="],
+
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
+ "js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="],
+
+ "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
+
+ "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
+
+ "lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="],
+
+ "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
+
+ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
+
+ "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
+
+ "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
+
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
+ "outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="],
+
+ "p-filter": ["p-filter@2.1.0", "", { "dependencies": { "p-map": "^2.0.0" } }, "sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw=="],
+
+ "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
+
+ "p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
+
+ "p-map": ["p-map@2.1.0", "", {}, "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="],
+
+ "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
+
+ "package-manager-detector": ["package-manager-detector@0.2.11", "", { "dependencies": { "quansync": "^0.2.7" } }, "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ=="],
+
"parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
+ "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
+
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
+ "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="],
+
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+ "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
+
+ "pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="],
+
+ "prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="],
+
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
+ "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
+
+ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
+
+ "read-yaml-file": ["read-yaml-file@1.1.0", "", { "dependencies": { "graceful-fs": "^4.1.5", "js-yaml": "^3.6.1", "pify": "^4.0.1", "strip-bom": "^3.0.0" } }, "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA=="],
+
+ "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
+
+ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
+
+ "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
+
+ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
+
+ "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
+
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
@@ -87,18 +259,48 @@
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
+ "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
+
+ "spawndamnit": ["spawndamnit@3.0.1", "", { "dependencies": { "cross-spawn": "^7.0.5", "signal-exit": "^4.0.1" } }, "sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg=="],
+
+ "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
+
+ "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
+
+ "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
+
"strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
+ "term-size": ["term-size@2.2.1", "", {}, "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg=="],
+
+ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
+
+ "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
+
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="],
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
+ "universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
+
+ "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
+
+ "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
+
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
+ "@manypkg/find-root/@types/node": ["@types/node@12.20.55", "", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="],
+
+ "@manypkg/find-root/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
+
+ "@manypkg/get-packages/@changesets/types": ["@changesets/types@4.1.0", "", {}, "sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw=="],
+
+ "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
+
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
}
}
diff --git a/package.json b/package.json
index 08951c9..2d29687 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "venfork",
- "version": "0.0.1",
+ "version": "0.2.0",
"description": "Create and manage private mirrors for vendor development",
"type": "module",
"bin": {
@@ -25,6 +25,7 @@
},
"files": [
"dist",
+ "assets",
"README.md",
"LICENSE"
],
diff --git a/src/commands.ts b/src/commands.ts
index 346dd80..7adc4f2 100644
--- a/src/commands.ts
+++ b/src/commands.ts
@@ -4,6 +4,7 @@ import os from 'node:os';
import path from 'node:path';
import * as p from '@clack/prompts';
import { $ } from 'execa';
+import { createConfigBranch, fetchVenforkConfig } from './config.js';
import {
AuthenticationError,
BranchNotFoundError,
@@ -19,14 +20,15 @@ import {
hasRemote,
isGitRepository,
} from './git.js';
-import { parseRepoName, parseRepoPath } from './utils.js';
+import { parseOwner, parseRepoName, parseRepoPath } from './utils.js';
/**
* Setup command: Create private mirror and public fork
*/
export async function setupCommand(
upstreamUrl?: string,
- vendorName?: string
+ privateMirrorName?: string,
+ organization?: string
): Promise {
p.intro('🔧 Venfork Setup');
@@ -37,50 +39,84 @@ export async function setupCommand(
}
// Get configuration from user or use provided arguments
- let config: { upstreamUrl: string; vendorName: string };
+ let finalUpstreamUrl = upstreamUrl;
+ let finalPrivateMirrorName = privateMirrorName;
+
+ // Prompt for upstream URL only if not provided
+ if (!finalUpstreamUrl) {
+ const response = await p.text({
+ message: 'Upstream repository URL?',
+ placeholder: 'git@github.com:google/project.git',
+ validate: (value) => {
+ if (!value) return 'Repository URL is required';
+ if (!value.includes('github.com')) return 'Must be a GitHub URL';
+ },
+ });
- if (upstreamUrl && vendorName) {
- config = { upstreamUrl, vendorName };
- } else {
- const groupResult = await p.group(
- {
- upstreamUrl: () =>
- p.text({
- message: 'Upstream repository URL?',
- placeholder: 'git@github.com:google/project.git',
- defaultValue: upstreamUrl,
- validate: (value) => {
- if (!value) return 'Repository URL is required';
- if (!value.includes('github.com')) return 'Must be a GitHub URL';
- },
- }),
- vendorName: ({ results }) =>
- p.text({
- message: 'Private vendor repo name?',
- placeholder: `${parseRepoName(results.upstreamUrl as string)}-vendor`,
- defaultValue:
- vendorName ||
- `${parseRepoName(results.upstreamUrl as string)}-vendor`,
- validate: (value) => {
- if (!value) return 'Vendor repo name is required';
- if (!/^[a-zA-Z0-9-_]+$/.test(value))
- return 'Name can only contain letters, numbers, hyphens, and underscores';
- },
- }),
+ if (p.isCancel(response)) {
+ p.cancel('Operation cancelled');
+ process.exit(0);
+ }
+
+ finalUpstreamUrl = response as string;
+ }
+
+ // Prompt for private mirror name only if not provided
+ if (!finalPrivateMirrorName) {
+ const defaultName = `${parseRepoName(finalUpstreamUrl)}-private`;
+ const response = await p.text({
+ message: 'Private mirror repo name?',
+ placeholder: defaultName,
+ defaultValue: defaultName,
+ validate: (value) => {
+ if (!value) return 'Private mirror repo name is required';
+ if (!/^[a-zA-Z0-9-_]+$/.test(value))
+ return 'Name can only contain letters, numbers, hyphens, and underscores';
},
- {
- onCancel: () => {
- p.cancel('Operation cancelled');
- process.exit(0);
- },
- }
- );
- config = groupResult as { upstreamUrl: string; vendorName: string };
+ });
+
+ if (p.isCancel(response)) {
+ p.cancel('Operation cancelled');
+ process.exit(0);
+ }
+
+ finalPrivateMirrorName = response as string;
}
+ const config = {
+ upstreamUrl: finalUpstreamUrl,
+ privateMirrorName: finalPrivateMirrorName,
+ };
+
const s = p.spinner();
const username = await getGitHubUsername();
+ // If organization is the user's personal account, treat it as no organization
+ if (organization && organization === username) {
+ organization = undefined;
+ }
+
+ // If no organization is specified, confirm before using personal account
+ if (!organization) {
+ p.log.warn('⚠️ No organization specified');
+ p.log.info(
+ `Repos will be created under your personal account (username: ${username})`
+ );
+
+ const confirmed = await p.confirm({
+ message: 'Continue with personal account?',
+ initialValue: false,
+ });
+
+ if (p.isCancel(confirmed) || !confirmed) {
+ p.outro('❌ Setup cancelled');
+ process.exit(0);
+ }
+ }
+
+ // Determine the account owner (org or user)
+ const owner = organization || username;
+
// Generate unique temp directory in OS temp folder
const uniqueId = randomBytes(8).toString('hex');
const tempDir = path.join(os.tmpdir(), `venfork-${uniqueId}`);
@@ -112,42 +148,65 @@ export async function setupCommand(
// Step 1: Create public fork
s.start('Creating public fork of upstream repository');
const upstreamRepoPath = parseRepoPath(config.upstreamUrl);
- await $`gh repo fork ${upstreamRepoPath} --clone=false`;
+ if (organization) {
+ await $`gh repo fork ${upstreamRepoPath} --clone=false --org ${organization}`;
+ } else {
+ await $`gh repo fork ${upstreamRepoPath} --clone=false`;
+ }
s.stop('Public fork created');
// Get the public fork name (same as upstream repo name)
const publicForkName = parseRepoName(config.upstreamUrl);
- // Step 2: Create private vendor repository
- s.start('Creating private vendor repository');
- await $`gh repo create ${config.vendorName} --private --clone=false`;
- s.stop('Private vendor repository created');
+ // Step 2: Create private mirror repository
+ s.start('Creating private mirror repository');
+ const privateMirrorRepoName = organization
+ ? `${organization}/${config.privateMirrorName}`
+ : config.privateMirrorName;
+ await $`gh repo create ${privateMirrorRepoName} --private --clone=false`;
+ s.stop('Private mirror repository created');
// Step 3: Clone upstream to temp directory
s.start('Cloning upstream repository');
- await $`git clone --bare ${config.upstreamUrl} ${tempDir}`;
+ await $`git clone ${config.upstreamUrl} ${tempDir}`;
s.stop('Upstream cloned');
- // Step 4: Push to private vendor repo
- s.start('Pushing to private vendor repository');
+ // Step 4: Detect default branch from upstream
+ s.start('Detecting default branch');
+ const result = await $({
+ cwd: tempDir,
+ reject: false,
+ })`git symbolic-ref refs/remotes/origin/HEAD`;
+
+ let defaultBranch = 'main';
+ if (result.exitCode === 0) {
+ const match = result.stdout.trim().match(/refs\/remotes\/origin\/(.+)$/);
+ if (match?.[1]) {
+ defaultBranch = match[1];
+ }
+ }
+ s.stop(`Default branch: ${defaultBranch}`);
+
+ // Step 5: Push default branch to private mirror repo
+ s.start(`Pushing ${defaultBranch} to private mirror repository`);
await $({
cwd: tempDir,
- })`git push --mirror git@github.com:${username}/${config.vendorName}.git`;
- s.stop('Pushed to private vendor repository');
+ })`git push git@github.com:${owner}/${config.privateMirrorName}.git ${defaultBranch}:${defaultBranch}`;
+ s.stop('Pushed to private mirror repository');
- // Step 5: Clone private vendor repo locally
- s.start('Cloning private vendor repository locally');
- await $`git clone git@github.com:${username}/${config.vendorName}.git`;
- s.stop('Private vendor repository cloned');
+ // Step 6: Clone private mirror repo locally
+ s.start('Cloning private mirror repository locally');
+ await $`git clone git@github.com:${owner}/${config.privateMirrorName}.git`;
+ s.stop('Private mirror repository cloned');
- // Step 6: Configure remotes
+ // Step 7: Configure remotes
s.start('Configuring git remotes');
- const repoDir = config.vendorName;
+ const repoDir = config.privateMirrorName;
// Add public fork remote
await $({
cwd: repoDir,
- })`git remote add public git@github.com:${username}/${publicForkName}.git`;
+ })`git remote add public git@github.com:${owner}/${publicForkName}.git`;
// Add upstream remote (with push disabled)
await $({ cwd: repoDir })`git remote add upstream ${config.upstreamUrl}`;
@@ -155,6 +214,12 @@ export async function setupCommand(
s.stop('Git remotes configured');
+ // Step 8: Create and push venfork config branch
+ s.start('Creating venfork configuration');
+ const publicForkUrl = `git@github.com:${owner}/${publicForkName}.git`;
+ await createConfigBranch(repoDir, publicForkUrl, config.upstreamUrl);
+ s.stop('Venfork configuration created');
+
// Show remote configuration
const remotesOutput = await $({ cwd: repoDir })`git remote -v`;
const remotesText = remotesOutput.stdout;
@@ -162,8 +227,8 @@ export async function setupCommand(
p.note(remotesText.trim(), 'Git Remote Configuration');
p.note(
- `Private Mirror: https://github.com/${username}/${config.vendorName} (for internal work)
-Public Fork: https://github.com/${username}/${publicForkName} (for staging to upstream)
+ `Private Mirror: https://github.com/${owner}/${config.privateMirrorName} (for internal work)
+Public Fork: https://github.com/${owner}/${publicForkName} (for staging to upstream)
Upstream: ${config.upstreamUrl} (read-only)`,
'Repositories Created'
);
@@ -190,52 +255,260 @@ Upstream: ${config.upstreamUrl} (read-only)`,
}
/**
- * Sync command: Fetch from upstream and rebase current branch
+ * Clone command: Clone vendor repository and configure all remotes
*/
-export async function syncCommand(targetBranch?: string): Promise {
- p.intro('🔄 Venfork Sync');
+export async function cloneCommand(vendorRepoUrl?: string): Promise {
+ p.intro('🔧 Venfork Clone');
+
+ // Validate vendor repo URL provided
+ if (!vendorRepoUrl) {
+ p.log.error('Vendor repository URL is required');
+ p.outro('❌ Clone failed');
+ process.exit(1);
+ }
+
+ // Step 1: Check GitHub CLI authentication
+ const isAuthenticated = await checkGhAuth();
+ if (!isAuthenticated) {
+ throw new AuthenticationError();
+ }
const s = p.spinner();
try {
- // Get current branch
- const currentBranch = await getCurrentBranch();
- if (!currentBranch) {
- throw new Error('Could not determine current branch');
+ // Parse vendor repo details
+ const vendorRepoName = parseRepoName(vendorRepoUrl);
+ const owner = parseOwner(vendorRepoUrl);
+
+ if (!owner || !vendorRepoName) {
+ throw new Error('Invalid vendor repository URL');
+ }
+
+ // Check if directory already exists
+ try {
+ await $`test -d ${vendorRepoName}`;
+ p.log.error(`Directory '${vendorRepoName}' already exists.`);
+ p.outro('❌ Clone failed');
+ process.exit(1);
+ } catch {
+ // Directory doesn't exist, good to proceed
+ }
+
+ // Step 2: Clone vendor repository
+ s.start('Cloning vendor repository');
+ await $`git clone ${vendorRepoUrl}`;
+ s.stop('Vendor repository cloned');
+
+ // Step 3: Try to fetch venfork config
+ s.start('Fetching venfork configuration');
+ const config = await fetchVenforkConfig(vendorRepoUrl);
+
+ let publicForkUrl: string;
+ let upstreamUrl: string;
+
+ if (config) {
+ // Config found! Use the URLs from config
+ publicForkUrl = config.publicForkUrl;
+ upstreamUrl = config.upstreamUrl;
+
+ const publicRepoPath = parseRepoPath(publicForkUrl);
+ const upstreamRepoPath = parseRepoPath(upstreamUrl);
+
+ s.stop('Configuration found');
+ p.log.success(`✓ Using config from venfork-config branch`);
+ p.note(
+ `Public fork: ${publicRepoPath}\nUpstream: ${upstreamRepoPath}`,
+ 'Configuration'
+ );
+ } else {
+ // No config found, fall back to auto-detection
+ s.stop('No configuration found, using auto-detection');
+
+ // Step 3a: Auto-detect public fork
+ s.start('Detecting public fork');
+
+ // Try to strip -private suffix
+ let publicRepoName = vendorRepoName;
+ if (vendorRepoName.endsWith('-private')) {
+ publicRepoName = vendorRepoName.replace(/-private$/, '');
+ }
+
+ // Verify public fork exists
+ try {
+ await $`gh repo view ${owner}/${publicRepoName}`;
+ publicForkUrl = `git@github.com:${owner}/${publicRepoName}.git`;
+ s.stop(`Found public fork: ${owner}/${publicRepoName}`);
+ } catch {
+ s.stop('Public fork not found');
+
+ p.log.warn('⚠️ Could not auto-detect public fork.');
+ p.note(`Tried: ${owner}/${publicRepoName}`, 'Detection Failed');
+
+ const response = await p.text({
+ message: 'Please provide the public fork URL:',
+ placeholder: 'git@github.com:owner/repo.git',
+ });
+
+ if (p.isCancel(response)) {
+ p.outro('❌ Clone cancelled');
+ process.exit(1);
+ }
+
+ publicForkUrl = response as string;
+ publicRepoName = parseRepoName(publicForkUrl);
+ }
+
+ // Step 3b: Auto-detect upstream from public fork's parent
+ s.start('Detecting upstream repository');
+
+ try {
+ const result =
+ await $`gh repo view ${owner}/${publicRepoName} --json parent --jq '.parent.url'`;
+ upstreamUrl = result.stdout.trim();
+
+ if (!upstreamUrl || upstreamUrl === 'null') {
+ throw new Error('No parent found');
+ }
+
+ const upstreamPath = parseRepoPath(upstreamUrl);
+ s.stop(`Found upstream: ${upstreamPath}`);
+ } catch {
+ s.stop('Upstream not found');
+
+ p.log.warn('⚠️ Public fork has no parent repository.');
+
+ const response = await p.text({
+ message: 'Please provide the upstream URL:',
+ placeholder: 'git@github.com:original/repo.git',
+ });
+
+ if (p.isCancel(response)) {
+ p.outro('❌ Clone cancelled');
+ process.exit(1);
+ }
+
+ upstreamUrl = response as string;
+ }
}
+ // Step 4: Configure remotes
+ s.start('Configuring git remotes');
+
+ // origin is already configured from clone
+
+ // Add public fork remote
+ await $({ cwd: vendorRepoName })`git remote add public ${publicForkUrl}`;
+
+ // Add upstream remote (with push disabled)
+ await $({ cwd: vendorRepoName })`git remote add upstream ${upstreamUrl}`;
+ await $({
+ cwd: vendorRepoName,
+ })`git remote set-url --push upstream DISABLE`;
+
+ s.stop('Git remotes configured');
+
+ // Step 5: Show configuration
+ const remotesOutput = await $({ cwd: vendorRepoName })`git remote -v`;
+ const remotesText = remotesOutput.stdout;
+
+ p.note(remotesText.trim(), 'Git Remote Configuration');
+
+ // Step 6: Success output
+ p.outro(
+ `✨ Clone complete!\n\nNext steps:
+ cd ${vendorRepoName}
+ venfork sync # Sync with upstream
+ git checkout -b feature-branch
+ # Do your work...
+ venfork stage feature-branch`
+ );
+ } catch (error) {
+ s.stop('Error occurred');
+ p.log.error(error instanceof Error ? error.message : String(error));
+ p.outro('❌ Clone failed');
+ process.exit(1);
+ }
+}
+
+/**
+ * Sync command: Update default branches of origin and public to match upstream
+ */
+export async function syncCommand(targetBranch?: string): Promise {
+ p.intro('🔄 Venfork Sync');
+
+ const s = p.spinner();
+
+ try {
// Step 1: Fetch from upstream
s.start('Fetching from upstream');
await $`git fetch upstream`;
- s.stop('Fetched from upstream');
+ await $`git fetch origin`;
+ await $`git fetch public`;
+ s.stop('Fetched from all remotes');
// Step 2: Detect default branch if not specified
- const branch = targetBranch || (await getDefaultBranch('upstream'));
+ const defaultBranch = targetBranch || (await getDefaultBranch('upstream'));
- // Step 3: Rebase current branch on upstream
- s.start(`Rebasing ${currentBranch} on upstream/${branch}`);
- try {
- await $`git rebase upstream/${branch}`;
- s.stop('Rebase successful');
+ // Step 3: Check for divergence
+ s.start('Checking for divergent commits');
- p.outro(
- `✨ Sync complete! ${currentBranch} is now up to date with upstream/${branch}`
- );
- } catch (_rebaseError) {
- s.stop('Rebase conflicts detected');
+ const checkDivergence = async (remote: string): Promise => {
+ try {
+ const result =
+ await $`git rev-list --count upstream/${defaultBranch}..${remote}/${defaultBranch}`;
+ return Number.parseInt(result.stdout.trim(), 10);
+ } catch {
+ // Remote branch might not exist yet (first sync)
+ return 0;
+ }
+ };
+
+ const originDivergence = await checkDivergence('origin');
+ const publicDivergence = await checkDivergence('public');
+ s.stop('Checked for divergence');
+
+ // Step 4: Warn if divergent commits exist
+ if (originDivergence > 0 || publicDivergence > 0) {
+ const warnings: string[] = [];
+ if (originDivergence > 0) {
+ warnings.push(
+ ` • origin/${defaultBranch} has ${originDivergence} commit(s) not in upstream`
+ );
+ }
+ if (publicDivergence > 0) {
+ warnings.push(
+ ` • public/${defaultBranch} has ${publicDivergence} commit(s) not in upstream`
+ );
+ }
+
+ p.log.warn('Divergent commits detected:');
p.note(
- `Git rebase has conflicts. To resolve:
- 1. Fix conflicts in the listed files
- 2. Stage resolved files: git add
- 3. Continue rebase: git rebase --continue
- 4. Or abort: git rebase --abort`,
- 'Conflict Resolution'
+ `${warnings.join('\n')}
+
+This suggests commits were made directly to the default branch.
+Force syncing will LOSE these commits.
+
+To preserve them: manually rebase or cherry-pick before running sync.
+To force sync anyway: git push origin upstream/${defaultBranch}:${defaultBranch} -f`,
+ '⚠️ Warning'
);
- p.outro('⚠️ Please resolve conflicts and continue the rebase');
+ p.outro('❌ Sync aborted to prevent data loss');
process.exit(1);
}
+
+ // Step 5: Push upstream default branch to origin and public
+ s.start(`Syncing ${defaultBranch} to origin and public`);
+
+ await $`git push origin upstream/${defaultBranch}:${defaultBranch} --force`;
+ await $`git push public upstream/${defaultBranch}:${defaultBranch} --force`;
+
+ s.stop('Synced to all remotes');
+
+ p.outro(
+ `✨ Sync complete! origin/${defaultBranch} and public/${defaultBranch} are now up to date with upstream/${defaultBranch}`
+ );
} catch (error) {
s.stop('Error occurred');
p.log.error(error instanceof Error ? error.message : String(error));
@@ -418,21 +691,32 @@ export function showHelp(): void {
p.intro('🔧 Venfork - Private Repository Mirrors for Vendor Development');
p.note(
- `venfork setup [name]
+ `venfork setup [name] [--org ]
Create private mirror + public fork for vendor workflow
+ Options:
+ • --org Create repos under organization instead of user account
+
Creates:
- • Private mirror (yourname/project-vendor) - internal work
+ • Private mirror (yourname/project-private) - internal work
• Public fork (yourname/project) - staging for upstream
• Configures remotes: origin, public, upstream
+venfork clone
+ Clone an existing vendor setup and configure remotes automatically
+
+ Auto-detects:
+ • Public fork (strips -private suffix)
+ • Upstream repository (from public fork's parent)
+ • Configures all three remotes (origin, public, upstream)
+
venfork status
Show current repository setup and configuration
Check which remotes are configured and setup completion
venfork sync [branch]
- Fetch from upstream and rebase current branch (default: main)
- Keeps your private work up-to-date with upstream
+ Update default branches of origin and public to match upstream
+ Syncs main/master branch without affecting your current work
venfork stage
Push branch to public fork for PR to upstream
@@ -444,7 +728,10 @@ venfork stage
`# One-time setup
venfork setup git@github.com:awesome/project.git
-cd project-vendor
+# Or for organization repos:
+venfork setup git@github.com:awesome/project.git --org my-company
+
+cd project-private
# Work privately (juniors can learn here!)
git checkout -b feature/new-thing
@@ -459,5 +746,20 @@ venfork stage feature/new-thing
'Example Workflow'
);
+ p.note(
+ `VENFORK_ORG - Default organization for repo creation
+ Set this to avoid typing --org every time
+
+ Priority:
+ 1. --org flag (highest priority)
+ 2. VENFORK_ORG environment variable
+ 3. Personal account (with confirmation prompt)
+
+ Example:
+ export VENFORK_ORG=my-company
+ venfork setup # Uses my-company automatically`,
+ 'Environment Variables'
+ );
+
p.outro('Built for teams who need private vendor workflows');
}
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..33811c8
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,131 @@
+import { randomBytes } from 'node:crypto';
+import { mkdir, rm, writeFile } from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { $ } from 'execa';
+
+/**
+ * Venfork configuration structure
+ */
+export interface VenforkConfig {
+ version: string;
+ publicForkUrl: string;
+ upstreamUrl: string;
+}
+
+const CONFIG_BRANCH = 'venfork-config';
+const CONFIG_DIR = '.venfork';
+const CONFIG_FILE = 'config.json';
+
+/**
+ * Creates and pushes a venfork config branch to the origin remote
+ *
+ * @param repoDir - Local repository directory
+ * @param publicForkUrl - URL of the public fork repository
+ * @param upstreamUrl - URL of the upstream repository
+ */
+export async function createConfigBranch(
+ repoDir: string,
+ publicForkUrl: string,
+ upstreamUrl: string
+): Promise {
+ // Create config object
+ const config: VenforkConfig = {
+ version: '1',
+ publicForkUrl,
+ upstreamUrl,
+ };
+
+ // Generate unique temp directory
+ const uniqueId = randomBytes(8).toString('hex');
+ const tempDir = path.join(os.tmpdir(), `venfork-config-${uniqueId}`);
+
+ try {
+ // Create temp directory structure
+ await mkdir(path.join(tempDir, CONFIG_DIR), { recursive: true });
+
+ // Write config file
+ await writeFile(
+ path.join(tempDir, CONFIG_DIR, CONFIG_FILE),
+ JSON.stringify(config, null, 2)
+ );
+
+ // Initialize git repo in temp directory
+ await $({ cwd: tempDir })`git init`;
+ await $({ cwd: tempDir })`git checkout --orphan ${CONFIG_BRANCH}`;
+
+ // Commit the config
+ await $({ cwd: tempDir })`git add ${CONFIG_DIR}/${CONFIG_FILE}`;
+ await $({
+ cwd: tempDir,
+ })`git commit -m ${'Initialize venfork configuration'}`;
+
+ // Get the origin remote URL from the main repo
+ const remoteResult = await $({ cwd: repoDir })`git remote get-url origin`;
+ const originUrl = remoteResult.stdout.trim();
+
+ // Push to origin
+ await $({
+ cwd: tempDir,
+ })`git push ${originUrl} ${CONFIG_BRANCH}:${CONFIG_BRANCH} --force`;
+ } finally {
+ // Clean up temp directory
+ try {
+ await rm(tempDir, { recursive: true, force: true });
+ } catch {
+ // Ignore cleanup errors
+ }
+ }
+}
+
+/**
+ * Fetches and reads the venfork config from a repository
+ *
+ * @param repoUrl - Repository URL to fetch config from
+ * @returns Config object if found, null otherwise
+ */
+export async function fetchVenforkConfig(
+ repoUrl: string
+): Promise {
+ // Generate unique temp directory
+ const uniqueId = randomBytes(8).toString('hex');
+ const tempDir = path.join(os.tmpdir(), `venfork-config-read-${uniqueId}`);
+
+ try {
+ // Try to clone just the config branch
+ const cloneResult = await $({
+ reject: false,
+ })`git clone --branch ${CONFIG_BRANCH} --single-branch --depth 1 ${repoUrl} ${tempDir}`;
+
+ if (cloneResult.exitCode !== 0) {
+ // Config branch doesn't exist
+ return null;
+ }
+
+ // Read the config file
+ const configPath = path.join(tempDir, CONFIG_DIR, CONFIG_FILE);
+ const readResult = await $({ reject: false })`cat ${configPath}`;
+
+ if (readResult.exitCode !== 0) {
+ return null;
+ }
+
+ const config = JSON.parse(readResult.stdout) as VenforkConfig;
+
+ // Validate config structure
+ if (!config.version || !config.publicForkUrl || !config.upstreamUrl) {
+ return null;
+ }
+
+ return config;
+ } catch {
+ return null;
+ } finally {
+ // Clean up temp directory
+ try {
+ await rm(tempDir, { recursive: true, force: true });
+ } catch {
+ // Ignore cleanup errors
+ }
+ }
+}
diff --git a/src/index.ts b/src/index.ts
index 96c6d87..7027b86 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -2,6 +2,7 @@
import * as p from '@clack/prompts';
import {
+ cloneCommand,
setupCommand,
showHelp,
stageCommand,
@@ -27,8 +28,44 @@ async function main(): Promise {
}
switch (command) {
- case 'setup':
- await setupCommand(args[1], args[2]);
+ case 'setup': {
+ // Parse --org flag (supports both --org value and --org=value)
+ let organization: string | undefined;
+ let upstreamUrl = args[1];
+ let privateMirrorName = args[2];
+
+ // Check for --org=value format
+ const orgEqualArg = args.find((arg) => arg.startsWith('--org='));
+ if (orgEqualArg) {
+ organization = orgEqualArg.split('=')[1];
+ // Remove --org=value from args
+ const filteredArgs = args.filter((arg) => !arg.startsWith('--org='));
+ upstreamUrl = filteredArgs[1];
+ privateMirrorName = filteredArgs[2];
+ } else {
+ // Check for --org value format
+ const orgIndex = args.indexOf('--org');
+ if (orgIndex !== -1) {
+ organization = args[orgIndex + 1];
+ // Remove --org and its value from args
+ const filteredArgs = args.filter(
+ (_, i) => i !== orgIndex && i !== orgIndex + 1
+ );
+ upstreamUrl = filteredArgs[1];
+ privateMirrorName = filteredArgs[2];
+ } else if (process.env.VENFORK_ORG) {
+ // Fall back to VENFORK_ORG environment variable
+ organization = process.env.VENFORK_ORG;
+ }
+ }
+ // If neither is set, organization remains undefined
+ // setupCommand will prompt for confirmation before using personal account
+
+ await setupCommand(upstreamUrl, privateMirrorName, organization);
+ break;
+ }
+ case 'clone':
+ await cloneCommand(args[1]);
break;
case 'sync':
await syncCommand(args[1]);
diff --git a/src/utils.ts b/src/utils.ts
index 1202c70..92d3a8b 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -30,3 +30,18 @@ export function parseRepoPath(url: string): string {
const match = url.match(/github\.com[:/](.+?)(?:\.git)?$/);
return match?.[1] || '';
}
+
+/**
+ * Extracts owner from a GitHub URL
+ *
+ * @param url - GitHub repository URL (SSH or HTTPS)
+ * @returns Owner/organization name (e.g., "facebook" from "github.com/facebook/react")
+ *
+ * @example
+ * parseOwner("git@github.com:facebook/react.git") // "facebook"
+ * parseOwner("https://github.com/vercel/next.js.git") // "vercel"
+ */
+export function parseOwner(url: string): string {
+ const match = url.match(/github\.com[:/](.+?)\/.+/);
+ return match?.[1] || '';
+}
diff --git a/tests/commands.test.ts b/tests/commands.test.ts
index 27a2d73..e862c52 100644
--- a/tests/commands.test.ts
+++ b/tests/commands.test.ts
@@ -10,12 +10,18 @@ interface RmCall {
options: { recursive: boolean; force: boolean };
}
+type SignalHandler = () => void | Promise;
+type MockResponse =
+ | { exitCode: number; stdout: string; stderr: string }
+ | (() => Promise);
+
// Track calls to our mocks
const execaCalls: string[] = [];
const rmCalls: RmCall[] = [];
-let signalHandlers = new Map();
+const signalHandlers = new Map();
let shouldHangOnFork = false;
-let mockResponses: Map = new Map();
+const mockResponses: Map = new Map();
+let confirmResponse = true; // Default to true for most tests
// Store originals
const originalProcessOn = process.on;
@@ -24,14 +30,20 @@ const originalProcessExit = process.exit;
// Mock execa BEFORE any imports
mock.module('execa', () => ({
+ // biome-ignore lint/suspicious/noExplicitAny: Mocking execa's complex overloaded types requires any
$: mock((stringsOrOptions: TemplateStringsArray | any, ...values: any[]) => {
let command: string;
- let options: any = {};
+ // biome-ignore lint/suspicious/noExplicitAny: Execa options type is complex
+ let _options: any = {};
// Handle both $`command` and $({ options })`command` patterns
- if (typeof stringsOrOptions === 'object' && !Array.isArray(stringsOrOptions)) {
+ if (
+ typeof stringsOrOptions === 'object' &&
+ !Array.isArray(stringsOrOptions)
+ ) {
// Called with options: $({ cwd: '...' })`command`
- options = stringsOrOptions;
+ _options = stringsOrOptions;
+ // biome-ignore lint/suspicious/noExplicitAny: Template literal values type
return mock((strings: TemplateStringsArray, ...vals: any[]) => {
command = String.raw({ raw: strings }, ...vals);
execaCalls.push(command);
@@ -50,7 +62,9 @@ function getMockExecaResponse(command: string) {
// Check if there's a specific mock response set for this test
for (const [pattern, response] of mockResponses.entries()) {
if (command.includes(pattern)) {
- return typeof response === 'function' ? response(command) : Promise.resolve(response);
+ return typeof response === 'function'
+ ? response(command)
+ : Promise.resolve(response);
}
}
@@ -72,18 +86,40 @@ function getMockExecaResponse(command: string) {
if (command.includes('git remote -v')) {
return Promise.resolve({
exitCode: 0,
- stdout: 'origin\tgit@github.com:test/repo.git (fetch)\norigin\tgit@github.com:test/repo.git (push)',
+ stdout:
+ 'origin\tgit@github.com:test/repo.git (fetch)\norigin\tgit@github.com:test/repo.git (push)',
stderr: '',
});
}
if (command.includes('git remote get-url')) {
- return Promise.resolve({ exitCode: 0, stdout: 'git@github.com:test/repo.git', stderr: '' });
+ return Promise.resolve({
+ exitCode: 0,
+ stdout: 'git@github.com:test/repo.git',
+ stderr: '',
+ });
}
if (command.includes('git remote set-head')) {
return Promise.resolve({ exitCode: 0, stdout: '', stderr: '' });
}
if (command.includes('git symbolic-ref')) {
- return Promise.resolve({ exitCode: 0, stdout: 'refs/remotes/upstream/main', stderr: '' });
+ return Promise.resolve({
+ exitCode: 0,
+ stdout: 'refs/remotes/upstream/main',
+ stderr: '',
+ });
+ }
+
+ // GitHub CLI commands for clone
+ if (command.includes('gh repo view') && command.includes('--json parent')) {
+ return Promise.resolve({
+ exitCode: 0,
+ stdout: 'https://github.com/upstream/original.git',
+ stderr: '',
+ });
+ }
+ if (command.includes('gh repo view')) {
+ // Checking if public fork exists
+ return Promise.resolve({ exitCode: 0, stdout: '', stderr: '' });
}
// For signal handler tests: make fork command hang to prevent cleanup
@@ -98,7 +134,7 @@ function getMockExecaResponse(command: string) {
// Mock fs.rm BEFORE any imports
mock.module('node:fs/promises', () => ({
- rm: mock((path: string, options: any) => {
+ rm: mock((path: string, options: { recursive: boolean; force: boolean }) => {
rmCalls.push({ path, options });
return Promise.resolve();
}),
@@ -114,20 +150,21 @@ mock.module('@clack/prompts', () => ({
note: mock(() => {}),
outro: mock(() => {}),
cancel: mock(() => {}),
- log: { error: mock(() => {}) },
+ log: { error: mock(() => {}), warn: mock(() => {}), info: mock(() => {}) },
group: mock(() => Promise.resolve({})),
text: mock(() => Promise.resolve('')),
- confirm: mock(() => Promise.resolve(false)), // Mock confirm to return false
+ confirm: mock(() => Promise.resolve(confirmResponse)), // Use dynamic confirmResponse
isCancel: mock(() => false),
}));
// Import commands (will use mocked execa, fs, and prompts)
import {
+ cloneCommand,
setupCommand,
- syncCommand,
+ showHelp,
stageCommand,
statusCommand,
- showHelp,
+ syncCommand,
} from '../src/commands.js';
/**
@@ -136,16 +173,16 @@ import {
*/
async function startSetupCommand(
upstreamUrl = 'git@github.com:test/repo.git',
- vendorName = 'test-vendor'
+ privateMirrorName = 'test-vendor'
): Promise {
// Enable fork hanging to keep setupCommand running for signal handler tests
shouldHangOnFork = true;
- const promise = setupCommand(upstreamUrl, vendorName);
+ const promise = setupCommand(upstreamUrl, privateMirrorName);
// Wait for async operations to complete (checkGhAuth, getGitHubUsername, etc.)
// Signal handlers are registered after these complete
- await new Promise(resolve => setTimeout(resolve, 50));
+ await new Promise((resolve) => setTimeout(resolve, 50));
// Suppress unhandled rejection warnings
promise.catch(() => {});
@@ -158,20 +195,27 @@ beforeEach(() => {
signalHandlers.clear();
shouldHangOnFork = false;
mockResponses.clear();
+ confirmResponse = true; // Reset to true for each test
+
+ // Clear VENFORK_ORG environment variable
+ delete process.env.VENFORK_ORG;
// Mock process methods
- process.on = ((event: string, handler: Function) => {
+ process.on = ((event: string, handler: SignalHandler) => {
signalHandlers.set(event, handler);
return process;
+ // biome-ignore lint/suspicious/noExplicitAny: Process.on return type is complex
}) as any;
- process.off = ((event: string, handler: Function) => {
+ process.off = ((event: string, _handler: SignalHandler) => {
signalHandlers.delete(event);
return process;
+ // biome-ignore lint/suspicious/noExplicitAny: Process.off return type is complex
}) as any;
process.exit = mock(() => {
throw new Error('process.exit called');
+ // biome-ignore lint/suspicious/noExplicitAny: Process.exit type is complex
}) as any;
});
@@ -276,19 +320,47 @@ describe('setupCommand - execution tests', () => {
});
describe('syncCommand', () => {
- test('fetches from upstream and rebases', async () => {
+ test('fetches from all remotes', async () => {
try {
await syncCommand('main');
} catch {
// Expected - may fail in test environment
}
- // Should have called git fetch and git rebase
- const fetchCalls = execaCalls.filter((cmd) => cmd.includes('git fetch upstream'));
- const rebaseCalls = execaCalls.filter((cmd) => cmd.includes('git rebase'));
+ // Should have called git fetch for all remotes
+ const fetchCalls = execaCalls.filter((cmd) => cmd.includes('git fetch'));
+
+ expect(fetchCalls.some((cmd) => cmd.includes('git fetch upstream'))).toBe(
+ true
+ );
+ expect(fetchCalls.some((cmd) => cmd.includes('git fetch origin'))).toBe(
+ true
+ );
+ expect(fetchCalls.some((cmd) => cmd.includes('git fetch public'))).toBe(
+ true
+ );
+ });
+
+ test('pushes to origin and public default branches', async () => {
+ try {
+ await syncCommand('main');
+ } catch {
+ // Expected
+ }
- expect(fetchCalls.length).toBeGreaterThanOrEqual(1);
- expect(rebaseCalls.length).toBeGreaterThanOrEqual(1);
+ // Should push upstream/main to origin/main and public/main
+ const pushCalls = execaCalls.filter((cmd) => cmd.includes('git push'));
+
+ expect(
+ pushCalls.some((cmd) =>
+ cmd.includes('git push origin upstream/main:main')
+ )
+ ).toBe(true);
+ expect(
+ pushCalls.some((cmd) =>
+ cmd.includes('git push public upstream/main:main')
+ )
+ ).toBe(true);
});
test('uses default branch when not specified', async () => {
@@ -299,11 +371,22 @@ describe('syncCommand', () => {
}
// Should call getDefaultBranch (already mocked to return 'main')
- const rebaseCalls = execaCalls.filter((cmd) => cmd.includes('git rebase'));
- expect(rebaseCalls.length).toBeGreaterThanOrEqual(1);
- if (rebaseCalls.length > 0) {
- expect(rebaseCalls[0]).toContain('upstream/main');
+ const pushCalls = execaCalls.filter((cmd) => cmd.includes('git push'));
+ expect(pushCalls.length).toBeGreaterThanOrEqual(2);
+ });
+
+ test('checks for divergent commits', async () => {
+ try {
+ await syncCommand('main');
+ } catch {
+ // Expected
}
+
+ // Should call git rev-list to check divergence
+ const revListCalls = execaCalls.filter((cmd) =>
+ cmd.includes('git rev-list --count')
+ );
+ expect(revListCalls.length).toBeGreaterThanOrEqual(2); // Check origin and public
});
});
@@ -382,10 +465,190 @@ describe('showHelp', () => {
});
});
+describe('setupCommand - organization tests', () => {
+ test('uses --org flag when organization is specified', async () => {
+ try {
+ await setupCommand(
+ 'git@github.com:test/repo.git',
+ 'test-vendor',
+ 'my-org'
+ );
+ } catch {
+ // Expected
+ }
+
+ // Should call gh repo fork with --org flag
+ const forkCalls = execaCalls.filter((cmd) => cmd.includes('gh repo fork'));
+ expect(forkCalls.length).toBeGreaterThan(0);
+ expect(forkCalls[0]).toContain('--org my-org');
+ });
+
+ test('creates private repo with org/repo format when organization specified', async () => {
+ try {
+ await setupCommand(
+ 'git@github.com:test/repo.git',
+ 'test-vendor',
+ 'my-org'
+ );
+ } catch {
+ // Expected
+ }
+
+ // Should call gh repo create with org/repo format
+ const createCalls = execaCalls.filter((cmd) =>
+ cmd.includes('gh repo create')
+ );
+ expect(createCalls.length).toBeGreaterThan(0);
+ expect(createCalls[0]).toContain('my-org/test-vendor');
+ });
+
+ test('uses organization in git URLs when specified', async () => {
+ try {
+ await setupCommand(
+ 'git@github.com:test/repo.git',
+ 'test-vendor',
+ 'my-org'
+ );
+ } catch {
+ // Expected
+ }
+
+ // Should use org in clone and remote URLs
+ const cloneCalls = execaCalls.filter((cmd) => cmd.includes('git clone'));
+ const remoteCalls = execaCalls.filter((cmd) =>
+ cmd.includes('git remote add')
+ );
+
+ expect(cloneCalls.some((cmd) => cmd.includes('my-org/test-vendor'))).toBe(
+ true
+ );
+ expect(remoteCalls.some((cmd) => cmd.includes('my-org/'))).toBe(true);
+ });
+
+ test('uses username when no organization specified', async () => {
+ try {
+ await setupCommand('git@github.com:test/repo.git', 'test-vendor');
+ } catch {
+ // Expected
+ }
+
+ // Should NOT include --org flag
+ const forkCalls = execaCalls.filter((cmd) => cmd.includes('gh repo fork'));
+ expect(forkCalls.length).toBeGreaterThan(0);
+ expect(forkCalls[0]).not.toContain('--org');
+
+ // Should use testuser (from mock) in URLs
+ const cloneCalls = execaCalls.filter((cmd) => cmd.includes('git clone'));
+ expect(cloneCalls.some((cmd) => cmd.includes('testuser/test-vendor'))).toBe(
+ true
+ );
+ });
+});
+
+describe('setupCommand - VENFORK_ORG environment variable', () => {
+ test('uses VENFORK_ORG when no --org flag is present', async () => {
+ // Simulate what index.ts does: read VENFORK_ORG and pass to setupCommand
+ process.env.VENFORK_ORG = 'env-org';
+ const organization = process.env.VENFORK_ORG;
+
+ try {
+ await setupCommand(
+ 'git@github.com:test/repo.git',
+ 'test-vendor',
+ organization
+ );
+ } catch {
+ // Expected
+ }
+
+ // Should use env-org in commands
+ const forkCalls = execaCalls.filter((cmd) => cmd.includes('gh repo fork'));
+ expect(forkCalls.length).toBeGreaterThan(0);
+ expect(forkCalls[0]).toContain('--org env-org');
+
+ // Should use env-org in URLs
+ const cloneCalls = execaCalls.filter((cmd) => cmd.includes('git clone'));
+ expect(cloneCalls.some((cmd) => cmd.includes('env-org/test-vendor'))).toBe(
+ true
+ );
+ });
+
+ test('--org flag overrides VENFORK_ORG', async () => {
+ process.env.VENFORK_ORG = 'env-org';
+
+ try {
+ await setupCommand(
+ 'git@github.com:test/repo.git',
+ 'test-vendor',
+ 'flag-org'
+ );
+ } catch {
+ // Expected
+ }
+
+ // Should use flag-org (not env-org)
+ const forkCalls = execaCalls.filter((cmd) => cmd.includes('gh repo fork'));
+ expect(forkCalls.length).toBeGreaterThan(0);
+ expect(forkCalls[0]).toContain('--org flag-org');
+ expect(forkCalls[0]).not.toContain('env-org');
+
+ // Should use flag-org in URLs
+ const cloneCalls = execaCalls.filter((cmd) => cmd.includes('git clone'));
+ expect(cloneCalls.some((cmd) => cmd.includes('flag-org/test-vendor'))).toBe(
+ true
+ );
+ expect(cloneCalls.some((cmd) => cmd.includes('env-org/'))).toBe(false);
+ });
+
+ test('prompts for confirmation when neither --org nor VENFORK_ORG is set', async () => {
+ // Ensure env var is not set
+ delete process.env.VENFORK_ORG;
+ // Confirm will return true (from beforeEach default)
+
+ try {
+ await setupCommand('git@github.com:test/repo.git', 'test-vendor');
+ } catch {
+ // Expected
+ }
+
+ // Should use testuser (after confirmation)
+ const cloneCalls = execaCalls.filter((cmd) => cmd.includes('git clone'));
+ expect(cloneCalls.some((cmd) => cmd.includes('testuser/test-vendor'))).toBe(
+ true
+ );
+ });
+
+ test('exits when user declines personal account confirmation', async () => {
+ // Ensure env var is not set
+ delete process.env.VENFORK_ORG;
+ // Set confirm to return false (decline)
+ confirmResponse = false;
+
+ try {
+ await setupCommand('git@github.com:test/repo.git', 'test-vendor');
+ } catch {
+ // Expected - command should exit
+ }
+
+ // Should call process.exit
+ expect(process.exit).toHaveBeenCalledWith(0);
+
+ // Should NOT create any repos
+ const createCalls = execaCalls.filter((cmd) =>
+ cmd.includes('gh repo create')
+ );
+ expect(createCalls.length).toBe(0);
+ });
+});
+
describe('setupCommand - error paths', () => {
test('throws AuthenticationError when not authenticated', async () => {
// Mock checkGhAuth to return false
- mockResponses.set('gh auth status', { exitCode: 1, stdout: '', stderr: 'not authenticated' });
+ mockResponses.set('gh auth status', {
+ exitCode: 1,
+ stdout: '',
+ stderr: 'not authenticated',
+ });
try {
await setupCommand('git@github.com:test/repo.git', 'test-vendor');
@@ -397,7 +660,9 @@ describe('setupCommand - error paths', () => {
test('handles error in catch block', async () => {
// Make fork command fail instead of hanging
- mockResponses.set('gh repo fork', () => Promise.reject(new Error('Fork failed')));
+ mockResponses.set('gh repo fork', () =>
+ Promise.reject(new Error('Fork failed'))
+ );
try {
await setupCommand('git@github.com:test/repo.git', 'test-vendor');
@@ -410,20 +675,98 @@ describe('setupCommand - error paths', () => {
});
});
-describe('syncCommand - error paths', () => {
- test('throws error when current branch cannot be determined', async () => {
- mockResponses.set('git branch --show-current', { exitCode: 0, stdout: '', stderr: '' });
+describe('cloneCommand', () => {
+ test('checks authentication first', async () => {
+ try {
+ await cloneCommand('git@github.com:acme/project-private.git');
+ } catch {
+ // Expected
+ }
+
+ // Should check authentication
+ const authCalls = execaCalls.filter((cmd) =>
+ cmd.includes('gh auth status')
+ );
+ expect(authCalls.length).toBeGreaterThan(0);
+ });
+ test('clones the vendor repository', async () => {
try {
- await syncCommand('main');
+ await cloneCommand('git@github.com:acme/project-private.git');
+ } catch {
+ // Expected
+ }
+
+ // Should clone the repo
+ const cloneCalls = execaCalls.filter((cmd) => cmd.includes('git clone'));
+ expect(cloneCalls.length).toBeGreaterThan(0);
+ expect(cloneCalls[0]).toContain('acme/project-private');
+ });
+
+ test('detects public fork by stripping -private suffix', async () => {
+ try {
+ await cloneCommand('git@github.com:acme/project-private.git');
+ } catch {
+ // Expected
+ }
+
+ // Should try to detect public fork
+ const viewCalls = execaCalls.filter((cmd) => cmd.includes('gh repo view'));
+ expect(viewCalls.length).toBeGreaterThan(0);
+ // Should check for 'project' (without -private)
+ expect(viewCalls.some((cmd) => cmd.includes('acme/project'))).toBe(true);
+ });
+
+ test('attempts to configure remotes', async () => {
+ try {
+ await cloneCommand('git@github.com:acme/project-private.git');
+ } catch {
+ // Expected - may fail due to interactive prompts in test environment
+ }
+
+ // Command should attempt to configure remotes
+ // Note: Full remote configuration may require interactive input mocking
+ const remoteCalls = execaCalls.filter((cmd) => cmd.includes('git remote'));
+ // Should attempt some remote operations
+ expect(remoteCalls.length).toBeGreaterThan(0);
+ });
+});
+
+describe('cloneCommand - error paths', () => {
+ test('throws AuthenticationError when not authenticated', async () => {
+ mockResponses.set('gh auth status', {
+ exitCode: 1,
+ stdout: '',
+ stderr: 'not authenticated',
+ });
+
+ try {
+ await cloneCommand('git@github.com:acme/project-private.git');
expect(true).toBe(false); // Should not reach here
} catch (error) {
expect(error).toBeDefined();
}
});
- test('handles rebase conflicts', async () => {
- mockResponses.set('git rebase', () => Promise.reject(new Error('Rebase conflict')));
+ test('requires vendor repo URL', async () => {
+ try {
+ await cloneCommand();
+ } catch {
+ // Expected - process.exit(1) throws
+ }
+
+ expect(process.exit).toHaveBeenCalledWith(1);
+ });
+});
+
+describe('syncCommand - error paths', () => {
+ test('aborts when origin has divergent commits', async () => {
+ // Mock rev-list to show origin has divergent commits
+ mockResponses.set('git rev-list --count upstream/main..origin/main', {
+ exitCode: 0,
+ stdout: '3',
+ stderr: '',
+ });
try {
await syncCommand('main');
@@ -434,8 +777,41 @@ describe('syncCommand - error paths', () => {
expect(process.exit).toHaveBeenCalledWith(1);
});
- test('handles general errors', async () => {
- mockResponses.set('git fetch', () => Promise.reject(new Error('Fetch failed')));
+ test('aborts when public has divergent commits', async () => {
+ // Mock rev-list to show public has divergent commits
+ mockResponses.set('git rev-list --count upstream/main..public/main', {
+ exitCode: 0,
+ stdout: '2',
+ stderr: '',
+ });
+
+ try {
+ await syncCommand('main');
+ } catch {
+ // Expected - process.exit(1) throws in tests
+ }
+
+ expect(process.exit).toHaveBeenCalledWith(1);
+ });
+
+ test('handles fetch errors', async () => {
+ mockResponses.set('git fetch', () =>
+ Promise.reject(new Error('Fetch failed'))
+ );
+
+ try {
+ await syncCommand('main');
+ } catch {
+ // Expected
+ }
+
+ expect(process.exit).toHaveBeenCalledWith(1);
+ });
+
+ test('handles push errors', async () => {
+ mockResponses.set('git push', () =>
+ Promise.reject(new Error('Push failed'))
+ );
try {
await syncCommand('main');
@@ -449,7 +825,11 @@ describe('syncCommand - error paths', () => {
describe('stageCommand - error paths', () => {
test('throws AuthenticationError when not authenticated', async () => {
- mockResponses.set('gh auth status', { exitCode: 1, stdout: '', stderr: 'not authenticated' });
+ mockResponses.set('gh auth status', {
+ exitCode: 1,
+ stdout: '',
+ stderr: 'not authenticated',
+ });
try {
await stageCommand('feature-branch');
@@ -460,7 +840,11 @@ describe('stageCommand - error paths', () => {
});
test('throws BranchNotFoundError when branch does not exist', async () => {
- mockResponses.set('git rev-parse --verify', { exitCode: 1, stdout: '', stderr: 'not found' });
+ mockResponses.set('git rev-parse --verify', {
+ exitCode: 1,
+ stdout: '',
+ stderr: 'not found',
+ });
try {
await stageCommand('nonexistent-branch');
@@ -472,7 +856,11 @@ describe('stageCommand - error paths', () => {
});
test('throws RemoteNotFoundError when public remote missing', async () => {
- mockResponses.set('git remote get-url public', { exitCode: 1, stdout: '', stderr: 'not found' });
+ mockResponses.set('git remote get-url public', {
+ exitCode: 1,
+ stdout: '',
+ stderr: 'not found',
+ });
try {
await stageCommand('feature-branch');
@@ -486,7 +874,11 @@ describe('stageCommand - error paths', () => {
describe('statusCommand - error paths', () => {
test('throws NotInRepositoryError when not in git repo', async () => {
- mockResponses.set('git rev-parse --git-dir', { exitCode: 128, stdout: '', stderr: 'not a git repository' });
+ mockResponses.set('git rev-parse --git-dir', {
+ exitCode: 128,
+ stdout: '',
+ stderr: 'not a git repository',
+ });
try {
await statusCommand();
@@ -506,12 +898,16 @@ describe('statusCommand - error paths', () => {
}
// Command should run successfully
- expect(execaCalls.some(cmd => cmd.includes('git remote -v'))).toBe(true);
+ expect(execaCalls.some((cmd) => cmd.includes('git remote -v'))).toBe(true);
});
test('shows incomplete setup message when missing remotes', async () => {
// Mock hasRemote to return false for public
- mockResponses.set('git remote get-url public', { exitCode: 1, stdout: '', stderr: 'not found' });
+ mockResponses.set('git remote get-url public', {
+ exitCode: 1,
+ stdout: '',
+ stderr: 'not found',
+ });
try {
await statusCommand();
@@ -520,6 +916,8 @@ describe('statusCommand - error paths', () => {
}
// Should check for remotes
- expect(execaCalls.some(cmd => cmd.includes('git remote get-url'))).toBe(true);
+ expect(execaCalls.some((cmd) => cmd.includes('git remote get-url'))).toBe(
+ true
+ );
});
});
diff --git a/tests/git.test.ts b/tests/git.test.ts
index e3649ad..9c19b8e 100644
--- a/tests/git.test.ts
+++ b/tests/git.test.ts
@@ -5,22 +5,32 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test';
* These tests verify the actual logic in git.ts functions
*/
+type ExecaOptions = Record;
+type MockResponse =
+ | { exitCode: number; stdout: string; stderr: string }
+ | (() => Promise);
+
// Track execa calls for verification
-const execaCalls: Array<{ command: string; options?: any }> = [];
+const execaCalls: Array<{ command: string; options?: ExecaOptions }> = [];
// Control mock behavior per test
-let mockResponses: Map = new Map();
+const mockResponses: Map = new Map();
// Mock execa BEFORE importing git.ts
mock.module('execa', () => ({
+ // biome-ignore lint/suspicious/noExplicitAny: Mocking execa's complex overloaded types requires any
$: mock((stringsOrOptions: TemplateStringsArray | any, ...values: any[]) => {
let command: string;
- let options: any = {};
+ let options: ExecaOptions = {};
// Handle both $`command` and $({ options })`command` patterns
- if (typeof stringsOrOptions === 'object' && !Array.isArray(stringsOrOptions)) {
+ if (
+ typeof stringsOrOptions === 'object' &&
+ !Array.isArray(stringsOrOptions)
+ ) {
// Called with options: $({ cwd: '...' })`command`
options = stringsOrOptions;
+ // biome-ignore lint/suspicious/noExplicitAny: Template literal values type
return mock((strings: TemplateStringsArray, ...vals: any[]) => {
command = String.raw({ raw: strings }, ...vals);
execaCalls.push({ command, options });
@@ -35,11 +45,13 @@ mock.module('execa', () => ({
}),
}));
-function getMockResponse(command: string, options: any = {}) {
+function getMockResponse(command: string, _options: ExecaOptions = {}) {
// Check if there's a specific mock response set for this test
for (const [pattern, response] of mockResponses.entries()) {
if (command.includes(pattern)) {
- return typeof response === 'function' ? response(command) : Promise.resolve(response);
+ return typeof response === 'function'
+ ? response(command)
+ : Promise.resolve(response);
}
}
@@ -103,7 +115,11 @@ beforeEach(() => {
describe('checkGhAuth', () => {
test('returns true when gh auth status succeeds', async () => {
- mockResponses.set('gh auth status', { exitCode: 0, stdout: '', stderr: '' });
+ mockResponses.set('gh auth status', {
+ exitCode: 0,
+ stdout: '',
+ stderr: '',
+ });
const result = await checkGhAuth();
@@ -125,7 +141,9 @@ describe('checkGhAuth', () => {
});
test('returns false when command throws error', async () => {
- mockResponses.set('gh auth status', () => Promise.reject(new Error('command failed')));
+ mockResponses.set('gh auth status', () =>
+ Promise.reject(new Error('command failed'))
+ );
const result = await checkGhAuth();
@@ -148,7 +166,9 @@ describe('getCurrentBranch', () => {
});
test('returns empty string on error', async () => {
- mockResponses.set('git branch', () => Promise.reject(new Error('not a git repo')));
+ mockResponses.set('git branch', () =>
+ Promise.reject(new Error('not a git repo'))
+ );
const result = await getCurrentBranch();
@@ -195,7 +215,9 @@ describe('getGitHubUsername', () => {
});
test('returns empty string on error', async () => {
- mockResponses.set('gh api', () => Promise.reject(new Error('not authenticated')));
+ mockResponses.set('gh api', () =>
+ Promise.reject(new Error('not authenticated'))
+ );
const result = await getGitHubUsername();
@@ -205,7 +227,11 @@ describe('getGitHubUsername', () => {
describe('isGitRepository', () => {
test('returns true when in git repository', async () => {
- mockResponses.set('git rev-parse', { exitCode: 0, stdout: '.git', stderr: '' });
+ mockResponses.set('git rev-parse', {
+ exitCode: 0,
+ stdout: '.git',
+ stderr: '',
+ });
const result = await isGitRepository();
@@ -226,7 +252,9 @@ describe('isGitRepository', () => {
});
test('returns false on command error', async () => {
- mockResponses.set('git rev-parse', () => Promise.reject(new Error('command failed')));
+ mockResponses.set('git rev-parse', () =>
+ Promise.reject(new Error('command failed'))
+ );
const result = await isGitRepository();
@@ -328,7 +356,9 @@ describe('getRemotes', () => {
});
test('returns empty object on command error', async () => {
- mockResponses.set('git remote', () => Promise.reject(new Error('command failed')));
+ mockResponses.set('git remote', () =>
+ Promise.reject(new Error('command failed'))
+ );
const result = await getRemotes();
@@ -393,7 +423,11 @@ describe('hasRemote', () => {
});
test('passes remote name to command', async () => {
- mockResponses.set('git remote get-url', { exitCode: 0, stdout: 'url', stderr: '' });
+ mockResponses.set('git remote get-url', {
+ exitCode: 0,
+ stdout: 'url',
+ stderr: '',
+ });
await hasRemote('my-custom-remote');
@@ -424,7 +458,9 @@ describe('getDefaultBranch', () => {
await getDefaultBranch('origin');
expect(execaCalls[0].command).toContain('git remote set-head origin -a');
- expect(execaCalls[1].command).toContain('git symbolic-ref refs/remotes/origin/HEAD');
+ expect(execaCalls[1].command).toContain(
+ 'git symbolic-ref refs/remotes/origin/HEAD'
+ );
});
test('uses upstream as default remote', async () => {
diff --git a/tests/utils.test.ts b/tests/utils.test.ts
index d6a69fb..818c2d8 100644
--- a/tests/utils.test.ts
+++ b/tests/utils.test.ts
@@ -1,5 +1,10 @@
import { describe, expect, test } from 'bun:test';
-import { DEFAULT_REPO_NAME, parseRepoName, parseRepoPath } from '../src/utils';
+import {
+ DEFAULT_REPO_NAME,
+ parseOwner,
+ parseRepoName,
+ parseRepoPath,
+} from '../src/utils';
describe('parseRepoName', () => {
test('extracts repo name from SSH URL with .git', () => {
@@ -96,3 +101,45 @@ describe('parseRepoPath', () => {
);
});
});
+
+describe('parseOwner', () => {
+ test('extracts owner from SSH URL with .git', () => {
+ expect(parseOwner('git@github.com:facebook/react.git')).toBe('facebook');
+ });
+
+ test('extracts owner from SSH URL without .git', () => {
+ expect(parseOwner('git@github.com:facebook/react')).toBe('facebook');
+ });
+
+ test('extracts owner from HTTPS URL with .git', () => {
+ expect(parseOwner('https://github.com/vercel/next.js.git')).toBe('vercel');
+ });
+
+ test('extracts owner from HTTPS URL without .git', () => {
+ expect(parseOwner('https://github.com/vercel/next.js')).toBe('vercel');
+ });
+
+ test('extracts owner with hyphens', () => {
+ expect(parseOwner('git@github.com:my-company/project.git')).toBe(
+ 'my-company'
+ );
+ });
+
+ test('extracts owner with dots', () => {
+ expect(parseOwner('https://github.com/my.org/project.git')).toBe('my.org');
+ });
+
+ test('returns empty string for invalid URL', () => {
+ expect(parseOwner('not-a-valid-url')).toBe('');
+ });
+
+ test('returns empty string for empty string', () => {
+ expect(parseOwner('')).toBe('');
+ });
+
+ test('extracts owner from URL with www', () => {
+ expect(parseOwner('https://www.github.com/facebook/react.git')).toBe(
+ 'facebook'
+ );
+ });
+});