Created
December 24, 2025 05:37
-
-
Save Innei/b980052436fcf2067ebd730b1f9558e2 to your computer and use it in GitHub Desktop.
worktree.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env -S npx tsx | |
| import { execSync } from 'node:child_process'; | |
| import { existsSync } from 'node:fs'; | |
| import { mkdir, readdir, symlink } from 'node:fs/promises'; | |
| import { dirname, join, relative } from 'node:path'; | |
| import { fileURLToPath } from 'node:url'; | |
| const __filename = fileURLToPath(import.meta.url); | |
| const __dirname = dirname(__filename); | |
| const ROOT_DIR = join(__dirname, '..'); | |
| const WORKFLOW_DIR = join(ROOT_DIR, '.workflow'); | |
| const RANDOM_NAMES = [ | |
| 'aurora', | |
| 'breeze', | |
| 'cascade', | |
| 'dawn', | |
| 'echo', | |
| 'flame', | |
| 'galaxy', | |
| 'horizon', | |
| 'iris', | |
| 'jasmine', | |
| 'kite', | |
| 'luna', | |
| 'mercury', | |
| 'nebula', | |
| 'orbit', | |
| 'prism', | |
| 'quartz', | |
| 'river', | |
| 'stellar', | |
| 'titan', | |
| 'unity', | |
| 'violet', | |
| 'whisper', | |
| 'zenith', | |
| 'azimuth', | |
| ] as const; | |
| async function findAllNodeModules(baseDir: string): Promise<string[]> { | |
| const nodeModulesPaths: string[] = []; | |
| async function scanDirectory(dir: string): Promise<void> { | |
| try { | |
| const entries = await readdir(dir, { withFileTypes: true }); | |
| for (const entry of entries) { | |
| const fullPath = join(dir, entry.name); | |
| if (entry.name === 'node_modules' && entry.isDirectory()) { | |
| nodeModulesPaths.push(fullPath); | |
| } else if ( | |
| entry.isDirectory() && | |
| entry.name !== '.git' && | |
| entry.name !== '.workflow' && | |
| !entry.name.startsWith('.') | |
| ) { | |
| await scanDirectory(fullPath); | |
| } | |
| } | |
| } catch (error) { | |
| // Ignore permission errors or inaccessible directories | |
| } | |
| } | |
| await scanDirectory(baseDir); | |
| return nodeModulesPaths; | |
| } | |
| function getCurrentBranch(): string { | |
| try { | |
| const branch = execSync('git rev-parse --abbrev-ref HEAD', { | |
| cwd: ROOT_DIR, | |
| encoding: 'utf-8', | |
| }).trim(); | |
| return branch === 'HEAD' ? 'main' : branch; | |
| } catch { | |
| return 'main'; | |
| } | |
| } | |
| function branchExists(branch: string): boolean { | |
| try { | |
| execSync(`git show-ref --verify refs/heads/${branch}`, { cwd: ROOT_DIR, stdio: 'pipe' }); | |
| return true; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| async function createWorkflow(baseBranch?: string, name?: string, force = false): Promise<string> { | |
| const workflowName = name || RANDOM_NAMES[Math.floor(Math.random() * RANDOM_NAMES.length)]; | |
| const workflowPath = join(WORKFLOW_DIR, workflowName); | |
| const branch = baseBranch || getCurrentBranch(); | |
| console.log(`Creating workflow: ${workflowName}`); | |
| console.log(` Base branch: ${branch}`); | |
| await mkdir(WORKFLOW_DIR, { recursive: true }); | |
| if (branchExists(workflowName)) { | |
| if (force) { | |
| console.log(` Branch '${workflowName}' already exists, deleting...`); | |
| execSync(`git branch -D ${workflowName}`, { cwd: ROOT_DIR, stdio: 'inherit' }); | |
| } else { | |
| console.error(`❌ Branch '${workflowName}' already exists`); | |
| console.log(` Use --force to delete existing branch and recreate`); | |
| console.log(` Or remove with: bun run scripts/createGitWorkflow.ts remove ${workflowName}`); | |
| process.exit(1); | |
| } | |
| } | |
| console.log(` Adding git worktree...`); | |
| execSync(`git worktree add -b ${workflowName} ${workflowPath} ${branch}`, { | |
| cwd: ROOT_DIR, | |
| stdio: 'inherit', | |
| }); | |
| console.log(' Linking node_modules from parent monorepo...'); | |
| try { | |
| const rootModulesPath = join(ROOT_DIR, 'node_modules'); | |
| const workflowModulesPath = join(workflowPath, 'node_modules'); | |
| if (!existsSync(workflowModulesPath)) { | |
| const relativeTarget = relative(workflowPath, rootModulesPath); | |
| await symlink(relativeTarget, workflowModulesPath, 'dir'); | |
| console.log(` ✅ node_modules linked: ${relativeTarget}`); | |
| } | |
| } catch (error) { | |
| console.log(` ⚠️ Failed to link node_modules: ${error}`); | |
| } | |
| const allNodeModules = await findAllNodeModules(ROOT_DIR); | |
| for (const nodeModulesPath of allNodeModules) { | |
| if (nodeModulesPath === join(ROOT_DIR, 'node_modules')) continue; | |
| const relativePath = relative(ROOT_DIR, nodeModulesPath); | |
| const symlinkPath = join(workflowPath, relativePath); | |
| await mkdir(dirname(symlinkPath), { recursive: true }); | |
| if (!existsSync(symlinkPath)) { | |
| const relativeTarget = relative(workflowPath, nodeModulesPath); | |
| await symlink(relativeTarget, symlinkPath, 'dir'); | |
| console.log(` Symlink: ${relativePath}`); | |
| } | |
| } | |
| console.log(`\n✅ Workflow created at: ${workflowPath}`); | |
| console.log(` Branch: ${workflowName}`); | |
| return workflowPath; | |
| } | |
| function listWorkflows(): void { | |
| console.log('Listing workflows:'); | |
| const result = execSync('git worktree list --porcelain', { cwd: ROOT_DIR, encoding: 'utf-8' }); | |
| console.log(result); | |
| } | |
| function removeWorkflow(name: string, deleteBranch = true): void { | |
| const workflowPath = join(WORKFLOW_DIR, name); | |
| console.log(`Removing workflow: ${name}`); | |
| try { | |
| if (existsSync(workflowPath)) { | |
| execSync(`git worktree remove ${workflowPath}`, { cwd: ROOT_DIR, stdio: 'inherit' }); | |
| console.log(`✅ Workflow removed: ${name}`); | |
| } else { | |
| console.log(`⚠️ Workflow directory not found: ${workflowPath}`); | |
| } | |
| if (deleteBranch) { | |
| console.log(` Deleting branch: ${name}`); | |
| try { | |
| execSync(`git branch -D ${name}`, { cwd: ROOT_DIR, stdio: 'inherit' }); | |
| console.log(`✅ Branch deleted: ${name}`); | |
| } catch (error) { | |
| console.warn(`⚠️ Branch ${name} may not exist or is still in use`); | |
| } | |
| } | |
| } catch (error) { | |
| console.error(`❌ Failed to remove workflow: ${name}`); | |
| console.error(error); | |
| } | |
| } | |
| function pruneWorkflows(): void { | |
| console.log('Pruning workflows...'); | |
| execSync('git worktree prune', { cwd: ROOT_DIR, stdio: 'inherit' }); | |
| console.log('✅ Workflows pruned'); | |
| } | |
| async function forcePurgeAll(): Promise<void> { | |
| console.log('Force purging all workflows in .workflow...'); | |
| try { | |
| if (!existsSync(WORKFLOW_DIR)) { | |
| console.log('ℹ️ .workflow directory does not exist'); | |
| return; | |
| } | |
| const entries = await readdir(WORKFLOW_DIR, { withFileTypes: true }); | |
| const workflows = entries | |
| .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.')) | |
| .map((entry) => entry.name); | |
| if (workflows.length === 0) { | |
| console.log('ℹ️ No workflows to purge'); | |
| return; | |
| } | |
| console.log(`Found ${workflows.length} workflow(s): ${workflows.join(', ')}`); | |
| for (const workflowName of workflows) { | |
| removeWorkflow(workflowName, true); | |
| } | |
| console.log('✅ All workflows purged'); | |
| } catch (error) { | |
| console.error('❌ Failed to purge workflows'); | |
| console.error(error); | |
| } | |
| } | |
| function submitPR(name: string, title?: string, body?: string): void { | |
| const workflowPath = join(WORKFLOW_DIR, name); | |
| console.log(`Submitting PR for workflow: ${name}`); | |
| try { | |
| console.log(' Checking for changes...'); | |
| const status = execSync('git status --porcelain', { | |
| cwd: workflowPath, | |
| encoding: 'utf-8', | |
| }).trim(); | |
| if (!status) { | |
| console.warn('⚠️ No changes to commit'); | |
| return; | |
| } | |
| console.log(' Adding all changes...'); | |
| execSync('git add -A', { cwd: workflowPath, stdio: 'inherit' }); | |
| console.log(' Committing changes...'); | |
| const commitMessage = title || `feat: ${name} implementation`; | |
| execSync(`git commit -m "${commitMessage}"`, { cwd: workflowPath, stdio: 'inherit' }); | |
| console.log(' Pushing branch...'); | |
| execSync(`git push -u origin ${name}`, { cwd: workflowPath, stdio: 'inherit' }); | |
| console.log(' Creating pull request...'); | |
| const baseBranch = execSync('git rev-parse --abbrev-ref @{u}', { | |
| cwd: workflowPath, | |
| encoding: 'utf-8', | |
| }) | |
| .trim() | |
| .split('/')[1]; | |
| let prCommand = `gh pr create --base ${baseBranch} --head ${name}`; | |
| if (title) prCommand += ` --title "${title}"`; | |
| if (body) prCommand += ` --body "${body}"`; | |
| execSync(prCommand, { cwd: workflowPath, stdio: 'inherit' }); | |
| console.log(`✅ PR created for branch: ${name}`); | |
| } catch (error) { | |
| console.error(`❌ Failed to submit PR: ${name}`); | |
| console.error(error); | |
| } | |
| } | |
| function listPRs(name?: string): void { | |
| console.log('Listing pull requests...'); | |
| try { | |
| let command = 'gh pr list --state open'; | |
| if (name) command += ` --head ${name}`; | |
| execSync(command, { cwd: ROOT_DIR, stdio: 'inherit' }); | |
| } catch (error) { | |
| console.error('❌ Failed to list PRs'); | |
| console.error(error); | |
| } | |
| } | |
| function mergePR(name: string, mergeMethod: 'merge' | 'squash' | 'rebase' = 'squash'): void { | |
| console.log(`Merging PR for branch: ${name}`); | |
| console.log(` Merge method: ${mergeMethod}`); | |
| try { | |
| execSync(`gh pr merge ${name} --${mergeMethod} --delete-branch`, { | |
| cwd: ROOT_DIR, | |
| stdio: 'inherit', | |
| }); | |
| console.log(`✅ PR merged: ${name}`); | |
| } catch (error) { | |
| console.error(`❌ Failed to merge PR: ${name}`); | |
| console.error(error); | |
| } | |
| } | |
| function closePR(name: string): void { | |
| console.log(`Closing PR for branch: ${name}`); | |
| try { | |
| execSync(`gh pr close ${name} --delete-branch`, { cwd: ROOT_DIR, stdio: 'inherit' }); | |
| console.log(`✅ PR closed: ${name}`); | |
| } catch (error) { | |
| console.error(`❌ Failed to close PR: ${name}`); | |
| console.error(error); | |
| } | |
| } | |
| async function main() { | |
| const command = process.argv[2]; | |
| const args = process.argv.slice(3); | |
| switch (command) { | |
| case 'create': | |
| const force = args.includes('--force'); | |
| const cleanArgs = args.filter((arg) => arg !== '--force'); | |
| await createWorkflow(cleanArgs[0], cleanArgs[1], force); | |
| break; | |
| case 'list': | |
| listWorkflows(); | |
| break; | |
| case 'remove': | |
| case 'rm': | |
| if (!args[0]) { | |
| console.error('Usage: bun run scripts/createGitWorkflow.ts remove <name> [--keep-branch]'); | |
| process.exit(1); | |
| } | |
| const keepBranch = args.includes('--keep-branch'); | |
| removeWorkflow(args[0], !keepBranch); | |
| break; | |
| case 'prune': | |
| pruneWorkflows(); | |
| break; | |
| case 'force-purge-all': | |
| await forcePurgeAll(); | |
| break; | |
| case 'pr': | |
| const prCommand = args[0]; | |
| const prArgs = args.slice(1); | |
| switch (prCommand) { | |
| case 'submit': | |
| if (!prArgs[0]) { | |
| console.error( | |
| 'Usage: bun run scripts/createGitWorkflow.ts pr submit <name> [title] [body]', | |
| ); | |
| process.exit(1); | |
| } | |
| submitPR(prArgs[0], prArgs[1], prArgs[2]); | |
| break; | |
| case 'list': | |
| listPRs(prArgs[0]); | |
| break; | |
| case 'merge': | |
| if (!prArgs[0]) { | |
| console.error('Usage: bun run scripts/createGitWorkflow.ts pr merge <name> [method]'); | |
| console.error(' method: merge | squash | rebase (default: squash)'); | |
| process.exit(1); | |
| } | |
| mergePR(prArgs[0], (prArgs[1] as 'merge' | 'squash' | 'rebase') || 'squash'); | |
| break; | |
| case 'close': | |
| if (!prArgs[0]) { | |
| console.error('Usage: bun run scripts/createGitWorkflow.ts pr close <name>'); | |
| process.exit(1); | |
| } | |
| closePR(prArgs[0]); | |
| break; | |
| default: | |
| console.error('Unknown PR command:', prCommand); | |
| console.log(` | |
| Available PR commands: | |
| submit <name> [title] [body] Commit, push and create PR | |
| list [name] List open PRs (optionally filter by branch) | |
| merge <name> [method] Merge PR (default: squash) | |
| close <name> Close PR and delete branch | |
| `); | |
| } | |
| break; | |
| default: | |
| console.log(` | |
| Usage: bun run scripts/createGitWorkflow.ts <command> [options] | |
| Commands: | |
| create [baseBranch] [name] [--force] Create a new workflow worktree | |
| list List all worktrees | |
| remove <name> Remove a worktree and its branch | |
| remove <name> --keep-branch Remove worktree but keep the branch | |
| prune Prune stale worktrees | |
| force-purge-all Force delete all workflows and branches in .workflow | |
| pr Pull request management | |
| Examples: | |
| bun run scripts/createGitWorkflow.ts create origin/main aurora | |
| bun run scripts/createGitWorkflow.ts create origin/main aurora --force | |
| bun run scripts/createGitWorkflow.ts list | |
| bun run scripts/createGitWorkflow.ts remove aurora | |
| bun run scripts/createGitWorkflow.ts remove aurora --keep-branch | |
| bun run scripts/createGitWorkflow.ts prune | |
| bun run scripts/createGitWorkflow.ts force-purge-all | |
| bun run scripts/createGitWorkflow.ts pr submit aurora "Fix bug" "Description" | |
| bun run scripts/createGitWorkflow.ts pr merge aurora squash | |
| bun run scripts/createGitWorkflow.ts pr close aurora | |
| `); | |
| } | |
| } | |
| main().catch(console.error); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment