diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6bf1a48..07e254f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -22,10 +22,9 @@ updates: directory: "/" schedule: interval: "weekly" + # github-actions does not support semver-specific cooldowns. cooldown: - semver-minor-days: 3 - semver-patch-days: 3 - semver-major-days: 30 + default-days: 3 open-pull-requests-limit: 10 groups: minor-and-patch: diff --git a/CLAUDE.md b/CLAUDE.md index 3fe10e1..2f57a1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,11 +28,43 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co This is a TypeScript CLI tool that provides Git workspace management utilities. The codebase is organized as follows: -- **src/bin/** - CLI entry points for all git commands (git-recent, git-merged, git-workspace, etc.) -- **src/lib/** - Core library functions and utilities shared across commands -- **src/tasks/** - Task implementations for workspace operations (clone, sync) +- **src/bin/** - CLI entry points, one file per installed binary (see CLI Commands below) +- **src/lib/** - Core library functions, grouped by theme, each with an `index.ts` barrel re-exported from `src/lib/index.ts`: + - `item/` - repository domain model (types, link parsing, location, filtering, status) + - `config/` - configuration and CLI plumbing (read/write git workspace config, filtered registry queries, shared commander options) + - `branches/` - git branch operations (list recent, exclude operational, select to checkout/delete) + - `worktrees/` - git worktree operations (list, add, select to remove) +- **src/tasks/** - Task implementations run on the concurrent task pool (clone/sync an item, submit item tasks) - **dist/** - Compiled JavaScript output (generated by TypeScript compiler) +Layering is one-directional: `bin → tasks → lib` and `bin → lib`. `lib` never imports from `tasks`. + +### CLI Commands + +Each binary is registered in `package.json` `bin` and invoked as a git subcommand (e.g. `git recent`). + +**Branch commands** (operate on the current repo, no workspace config required): + +- `git recent` - list recently checked-out branches, select one to check out +- `git merged` / `git squashed` - list merged/squashed branches, multi-select to delete +- `git stale` / `git tidy` - list branches, multi-select to delete +- `git list-remote` - list remote branches, select one to check out + +**Worktree command** - `git work` with subcommands (operates on the current repo): + +- `git work add ` - create a worktree under the `-worktrees` sibling folder +- `git work remove` - list worktrees, multi-select to remove + +**Workspace command** - `git workspace` with subcommands (backed by git config + registry): + +- `git workspace` / `git workspace list` - list registered repos (default subcommand) +- `git workspace add ` - register a repo +- `git workspace remove` - remove matching repos +- `git workspace clone` / `git workspace sync` - clone/sync matching repos via the task pool +- `git workspace config` - interactively write the git global workspace config + +`git work` and `git workspace` use [commander](https://github.com/tj/commander.js) subcommands in a single binary. The filter options (`-w/--workspace`, `--ns/--namespace`, `-h/--host`, `-s/--slug`, `-k/--keyword`) are shared via `withFilterOptions`. Workspace config is loaded lazily inside each action (not at startup) so `git workspace config` works before any config exists. + ### Key Components **Workspace Management**: The system uses git global config to store workspace configuration: diff --git a/README.md b/README.md index 9c7f75a..0c4bc1e 100644 --- a/README.md +++ b/README.md @@ -64,12 +64,27 @@ git squashed origin/master git tidy ``` +#### working with worktrees + +- add a worktree for the current repo, created under the `-worktrees` + sibling folder + +```bash +git work add +``` + +- list the current repo worktrees, multi select the ones you want to remove + +```bash +git work remove +``` + #### setup git workspaces - start by some configuration ```bash -git workspace-config +git workspace config ``` it will add the following git global config entries @@ -82,8 +97,8 @@ git.workspaces.default # your default workspace, i.e open-sourc - add a repo to your workspace ```bash -git workspace-add # adds to default workspace -git workspace-add -w # adds to workspace with provided name +git workspace add # adds to default workspace +git workspace add -w # adds to workspace with provided name ``` - view your workspace @@ -96,8 +111,8 @@ git workspace -w # describes workspace with provided name - clone your entire workspace ```bash -git workspace-clone -git workspace-clone -w +git workspace clone +git workspace clone -w ``` > [!NOTE] diff --git a/package.json b/package.json index 2d3f143..5ff1fef 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,8 @@ "git-squashed": "dist/bin/git-squashed.js", "git-stale": "dist/bin/git-stale.js", "git-tidy": "dist/bin/git-tidy.js", - "git-workspace": "dist/bin/git-workspace.js", - "git-workspace-add": "dist/bin/git-workspace-add.js", - "git-workspace-clone": "dist/bin/git-workspace-clone.js", - "git-workspace-config": "dist/bin/git-workspace-config.js", - "git-workspace-remove": "dist/bin/git-workspace-remove.js", - "git-workspace-sync": "dist/bin/git-workspace-sync.js" + "git-work": "dist/bin/git-work.js", + "git-workspace": "dist/bin/git-workspace.js" }, "scripts": { "build": "tsc", diff --git a/src/bin/git-list-remote.ts b/src/bin/git-list-remote.ts index b573727..da9c842 100644 --- a/src/bin/git-list-remote.ts +++ b/src/bin/git-list-remote.ts @@ -17,7 +17,7 @@ */ import { simpleGit } from 'simple-git'; -import { selectAndCheckoutBranch } from '../lib/select-and-checkout-branch'; +import { selectAndCheckoutBranch } from '../lib/branches'; const listRemoteBranches = async ({ pattern }: { pattern?: string }) => { const params = ['--remote']; diff --git a/src/bin/git-merged.ts b/src/bin/git-merged.ts index 85cb96a..584f1af 100644 --- a/src/bin/git-merged.ts +++ b/src/bin/git-merged.ts @@ -17,8 +17,10 @@ */ import { simpleGit } from 'simple-git'; -import { excludeOperationalBranches } from '../lib/exclude-operational-branches'; -import { selectAndDeleteBranches } from '../lib/select-and-delete-branches'; +import { + excludeOperationalBranches, + selectAndDeleteBranches +} from '../lib/branches'; const listMergedBranches = async ({ target }: { target?: string }) => { const params = ['--merged']; diff --git a/src/bin/git-recent.ts b/src/bin/git-recent.ts index 74eabe2..50a9144 100644 --- a/src/bin/git-recent.ts +++ b/src/bin/git-recent.ts @@ -15,8 +15,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { listRecentBranches } from '../lib/list-recent-branches'; -import { selectAndCheckoutBranch } from '../lib/select-and-checkout-branch'; +import { listRecentBranches, selectAndCheckoutBranch } from '../lib/branches'; listRecentBranches() .then(selectAndCheckoutBranch(/\d{4}-\d{2}-\d{2} (?.*)/)) diff --git a/src/bin/git-squashed.ts b/src/bin/git-squashed.ts index dcad516..56fb983 100644 --- a/src/bin/git-squashed.ts +++ b/src/bin/git-squashed.ts @@ -17,8 +17,10 @@ */ import { simpleGit } from 'simple-git'; -import { excludeOperationalBranches } from '../lib/exclude-operational-branches'; -import { selectAndDeleteBranches } from '../lib/select-and-delete-branches'; +import { + excludeOperationalBranches, + selectAndDeleteBranches +} from '../lib/branches'; const listSquashedBranches = async ({ target }: { target?: string }) => { const params = ['--no-contains']; diff --git a/src/bin/git-stale.ts b/src/bin/git-stale.ts index 8cea6f3..e319786 100644 --- a/src/bin/git-stale.ts +++ b/src/bin/git-stale.ts @@ -15,8 +15,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { listRecentBranches } from '../lib/list-recent-branches'; -import { selectAndDeleteBranches } from '../lib/select-and-delete-branches'; +import { listRecentBranches, selectAndDeleteBranches } from '../lib/branches'; const mainBranches = /^\d{4}-\d{2}-\d{2} (main|master|production|demo|website|development)$/; diff --git a/src/bin/git-tidy.ts b/src/bin/git-tidy.ts index f07a4f2..d46f65e 100644 --- a/src/bin/git-tidy.ts +++ b/src/bin/git-tidy.ts @@ -16,9 +16,11 @@ * limitations under the License. */ -import { excludeOperationalBranches } from '../lib/exclude-operational-branches'; -import { listRecentBranches } from '../lib/list-recent-branches'; -import { selectAndDeleteBranches } from '../lib/select-and-delete-branches'; +import { + excludeOperationalBranches, + listRecentBranches, + selectAndDeleteBranches +} from '../lib/branches'; listRecentBranches() .then(excludeOperationalBranches) diff --git a/src/bin/git-work.ts b/src/bin/git-work.ts new file mode 100644 index 0000000..4afc0d4 --- /dev/null +++ b/src/bin/git-work.ts @@ -0,0 +1,40 @@ +#! /usr/bin/env node + +/** + * Copyright 2023 Hasnae Rehioui + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Command } from 'commander'; + +import { addWorktree, listWorktrees, selectAndRemoveWorktrees } from '../lib'; + +const program = new Command(); +program.description('manage git worktrees for the current repo'); + +program + .command('add') + .description('add a worktree under the -worktrees sibling folder') + .argument('', 'worktree name') + .action(async (name) => { + await addWorktree(name); + }); + +program + .command('remove') + .description('list worktrees and select the ones to remove') + .action(async () => { + await listWorktrees().then(selectAndRemoveWorktrees); + }); + +program.parseAsync(process.argv).catch(console.error); diff --git a/src/bin/git-workspace-add.ts b/src/bin/git-workspace-add.ts deleted file mode 100644 index 66b9230..0000000 --- a/src/bin/git-workspace-add.ts +++ /dev/null @@ -1,37 +0,0 @@ -#! /usr/bin/env node - -/** - * Copyright 2023 Hasnae Rehioui - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { fromInput, getConfiguration, withProgram, WithProgram } from '../lib'; - -const addRepo: WithProgram = ({ registry }, program) => { - program - .description('add repo to workspace') - .argument('') - .action(async (urlConnection, opts) => { - const workspace = opts.workspace; - const item = fromInput({ urlConnection, workspace }); - if (!item) return; - - await registry - .add(item) - .then(({ workspace, urlConnection }) => - console.info({ workspace, urlConnection }) - ); - }); -}; - -getConfiguration().then(withProgram(addRepo)).catch(console.error); diff --git a/src/bin/git-workspace-clone.ts b/src/bin/git-workspace-clone.ts deleted file mode 100644 index 6ef1c42..0000000 --- a/src/bin/git-workspace-clone.ts +++ /dev/null @@ -1,54 +0,0 @@ -#! /usr/bin/env node - -/** - * Copyright 2023 Hasnae Rehioui - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { withCliProgress } from '@labset/task-pool-executor'; - -import { - getConfiguration, - Item, - itemFilter, - WithProgram, - withProgram -} from '../lib'; -import { cloneItemTask } from '../tasks'; - -const cloneRepos: WithProgram = ({ registry, workspacesRoot }, program) => { - program.description('clone workspace repos').action(async (opts) => { - const { workspace, namespace, host, slug, keyword } = opts; - await registry - .list(itemFilter({ workspace, namespace, host, slug, keyword })) - .then((items) => { - return itemsHandler({ items, workspacesRoot }); - }); - }); -}; - -const itemsHandler = ({ - items, - workspacesRoot -}: { - items: Item[]; - workspacesRoot: string; -}) => { - return withCliProgress((taskPool) => { - items - .map(cloneItemTask({ workspacesRoot })) - .forEach((t) => t && taskPool.submit(t)); - }); -}; - -getConfiguration().then(withProgram(cloneRepos)).catch(console.error); diff --git a/src/bin/git-workspace-sync.ts b/src/bin/git-workspace-sync.ts deleted file mode 100644 index 251db4f..0000000 --- a/src/bin/git-workspace-sync.ts +++ /dev/null @@ -1,54 +0,0 @@ -#! /usr/bin/env node - -/** - * Copyright 2023 Hasnae Rehioui - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { withCliProgress } from '@labset/task-pool-executor'; - -import { - getConfiguration, - Item, - itemFilter, - withProgram, - WithProgram -} from '../lib'; -import { syncItemTask } from '../tasks'; - -const syncRepos: WithProgram = ({ registry, workspacesRoot }, program) => { - program.description('sync workspace repos').action(async (opts) => { - const { workspace, namespace, host, slug, keyword } = opts; - await registry - .list(itemFilter({ workspace, namespace, host, slug, keyword })) - .then(async (items) => { - return await itemsHandler({ items, workspacesRoot }); - }); - }); -}; - -const itemsHandler = ({ - items, - workspacesRoot -}: { - items: Item[]; - workspacesRoot: string; -}) => { - return withCliProgress((taskPool) => { - items - .map(syncItemTask({ workspacesRoot })) - .forEach((t) => t && taskPool.submit(t)); - }); -}; - -getConfiguration().then(withProgram(syncRepos)).catch(console.error); diff --git a/src/bin/git-workspace.ts b/src/bin/git-workspace.ts index a6eeb6e..2ac74ff 100644 --- a/src/bin/git-workspace.ts +++ b/src/bin/git-workspace.ts @@ -15,67 +15,104 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { Command } from 'commander'; + import { + configureWorkspace, + fromInput, getConfiguration, - itemFilter, itemLocation, - withProgram, - WithProgram + itemStatus, + listWorkspaceItems, + withFilterOptions } from '../lib'; -import { itemStatus } from '../lib/item-status'; +import { cloneItemTask, submitItemTasks, syncItemTask } from '../tasks'; + +const program = new Command(); +program.description('manage git workspaces'); -const listRepos: WithProgram = ({ workspacesRoot, registry }, program) => { - program - .option('--dl, --display-location', 'display location only', false) - .option('--dc, --display-connection', 'display connection only', false) - .option('--ds, --display-slug', 'display slug only', false) - .option('--cl, --cloned', 'display cloned repos only', false) - .description('list repos registered on workspace') - .action(async (opts) => { - const { workspace, namespace, host, slug, keyword, cloned } = opts; - await registry - .list(itemFilter({ workspace, namespace, host, slug, keyword })) - .then(async (items) => { - return await Promise.all( - items.map((item) => - itemStatus({ workspacesRoot, item }) - ) - ); - }) - .then((itemsWithStatus) => { - const filtered = itemsWithStatus.filter((item) => { - return cloned ? item.status !== null : true; - }); - const { displayLocation, displayConnection, displaySlug } = - opts; - const displayOptionEnabled = - displayLocation || displayConnection || displaySlug; +withFilterOptions(program.command('list', { isDefault: true })) + .option('--dl, --display-location', 'display location only', false) + .option('--dc, --display-connection', 'display connection only', false) + .option('--ds, --display-slug', 'display slug only', false) + .option('--cl, --cloned', 'display cloned repos only', false) + .description('list repos registered on workspace') + .action(async (opts) => { + const { workspacesRoot, items } = await listWorkspaceItems(opts); + const itemsWithStatus = await Promise.all( + items.map((item) => itemStatus({ workspacesRoot, item })) + ); + const filtered = itemsWithStatus.filter((item) => + opts.cloned ? item.status !== null : true + ); + const { displayLocation, displayConnection, displaySlug } = opts; + const displayOptionEnabled = + displayLocation || displayConnection || displaySlug; - if (!displayOptionEnabled) { - console.table(filtered, [ - 'workspace', - 'namespace', - 'slug', - 'status' - ]); - return; - } + if (!displayOptionEnabled) { + console.table(filtered, [ + 'workspace', + 'namespace', + 'slug', + 'status' + ]); + return; + } - filtered.forEach((item) => { - if (displayLocation) { - const target = itemLocation({ - workspacesRoot, - item - }); - console.info(target); - } else if (displayConnection) { - console.info(item.urlConnection); - } else if (displaySlug) { - console.info(item.slug); - } - }); - }); + filtered.forEach((item) => { + if (displayLocation) { + console.info(itemLocation({ workspacesRoot, item })); + } else if (displayConnection) { + console.info(item.urlConnection); + } else if (displaySlug) { + console.info(item.slug); + } }); -}; + }); + +program + .command('add') + .description('add repo to workspace') + .argument('') + .option('-w, --workspace ', 'with workspace') + .action(async (urlConnection, opts) => { + const { registry, workspacesDefault } = await getConfiguration(); + const workspace = opts.workspace ?? workspacesDefault; + const item = fromInput({ urlConnection, workspace }); + if (!item) return; + await registry + .add(item) + .then(({ workspace, urlConnection }) => + console.info({ workspace, urlConnection }) + ); + }); + +withFilterOptions(program.command('remove')) + .description('remove workspace repos') + .action(async (opts) => { + const { registry, items } = await listWorkspaceItems(opts); + await Promise.all(items.map((item) => registry.remove(item.id))); + }); + +withFilterOptions(program.command('clone')) + .description('clone workspace repos') + .action(async (opts) => { + const { workspacesRoot, items } = await listWorkspaceItems(opts); + await submitItemTasks(items, cloneItemTask({ workspacesRoot })); + }); + +withFilterOptions(program.command('sync')) + .description('sync workspace repos') + .action(async (opts) => { + const { workspacesRoot, items } = await listWorkspaceItems(opts); + await submitItemTasks(items, syncItemTask({ workspacesRoot })); + }); + +program + .command('config') + .description('configure git workspaces') + .action(async () => { + await configureWorkspace(); + }); -getConfiguration().then(withProgram(listRepos)).catch(console.error); +program.parseAsync(process.argv).catch(console.error); diff --git a/src/lib/exclude-operational-branches.ts b/src/lib/branches/exclude-operational-branches.ts similarity index 100% rename from src/lib/exclude-operational-branches.ts rename to src/lib/branches/exclude-operational-branches.ts diff --git a/src/lib/git-raw-output-handler.ts b/src/lib/branches/git-raw-output-handler.ts similarity index 100% rename from src/lib/git-raw-output-handler.ts rename to src/lib/branches/git-raw-output-handler.ts diff --git a/src/lib/branches/index.ts b/src/lib/branches/index.ts new file mode 100644 index 0000000..5cce37b --- /dev/null +++ b/src/lib/branches/index.ts @@ -0,0 +1,20 @@ +/** + * Copyright 2023 Hasnae Rehioui + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './git-raw-output-handler'; +export * from './list-recent-branches'; +export * from './exclude-operational-branches'; +export * from './select-and-checkout-branch'; +export * from './select-and-delete-branches'; diff --git a/src/lib/list-recent-branches.ts b/src/lib/branches/list-recent-branches.ts similarity index 100% rename from src/lib/list-recent-branches.ts rename to src/lib/branches/list-recent-branches.ts diff --git a/src/lib/select-and-checkout-branch.test.ts b/src/lib/branches/select-and-checkout-branch.test.ts similarity index 100% rename from src/lib/select-and-checkout-branch.test.ts rename to src/lib/branches/select-and-checkout-branch.test.ts diff --git a/src/lib/select-and-checkout-branch.ts b/src/lib/branches/select-and-checkout-branch.ts similarity index 100% rename from src/lib/select-and-checkout-branch.ts rename to src/lib/branches/select-and-checkout-branch.ts diff --git a/src/lib/select-and-delete-branches.test.ts b/src/lib/branches/select-and-delete-branches.test.ts similarity index 100% rename from src/lib/select-and-delete-branches.test.ts rename to src/lib/branches/select-and-delete-branches.test.ts diff --git a/src/lib/select-and-delete-branches.ts b/src/lib/branches/select-and-delete-branches.ts similarity index 100% rename from src/lib/select-and-delete-branches.ts rename to src/lib/branches/select-and-delete-branches.ts diff --git a/src/bin/git-workspace-config.ts b/src/lib/config/configure-workspace.ts similarity index 76% rename from src/bin/git-workspace-config.ts rename to src/lib/config/configure-workspace.ts index cbb05d2..a03deca 100644 --- a/src/bin/git-workspace-config.ts +++ b/src/lib/config/configure-workspace.ts @@ -1,5 +1,3 @@ -#! /usr/bin/env node - /** * Copyright 2023 Hasnae Rehioui * @@ -15,16 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import * as os from 'os'; -import * as path from 'path'; +import * as os from 'node:os'; +import * as path from 'node:path'; import inquirer from 'inquirer'; import { simpleGit, GitConfigScope } from 'simple-git'; -import { Configuration } from '../lib'; - -const configureGitWorkspaceQuestions = async () => { - return inquirer.prompt([ +export const configureWorkspace = async () => { + const answer = await inquirer.prompt([ { name: 'workspacesRoot', type: 'input', @@ -38,12 +34,6 @@ const configureGitWorkspaceQuestions = async () => { default: 'projects' } ]); -}; - -const configureGitWorkspaceAnswers = async ( - answer: Omit | undefined -) => { - if (!answer) return; await simpleGit() .addConfig( 'labset.workspaces.root', @@ -58,7 +48,3 @@ const configureGitWorkspaceAnswers = async ( GitConfigScope.global ); }; - -configureGitWorkspaceQuestions() - .then(configureGitWorkspaceAnswers) - .catch(console.error); diff --git a/src/lib/get-configuration.ts b/src/lib/config/get-configuration.ts similarity index 98% rename from src/lib/get-configuration.ts rename to src/lib/config/get-configuration.ts index d09a80c..3e54eae 100644 --- a/src/lib/get-configuration.ts +++ b/src/lib/config/get-configuration.ts @@ -20,7 +20,7 @@ import { leveldbRegistry, Registry } from '@labset/leveldb-registry'; import camelCase from 'lodash/camelCase'; import { simpleGit, GitConfigScope } from 'simple-git'; -import { Item } from './types'; +import { Item } from '../item'; export type Configuration = { workspacesRoot: string; diff --git a/src/lib/config/index.ts b/src/lib/config/index.ts new file mode 100644 index 0000000..31bf082 --- /dev/null +++ b/src/lib/config/index.ts @@ -0,0 +1,19 @@ +/** + * Copyright 2023 Hasnae Rehioui + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './get-configuration'; +export * from './configure-workspace'; +export * from './list-workspace-items'; +export * from './with-filter-options'; diff --git a/src/lib/config/list-workspace-items.ts b/src/lib/config/list-workspace-items.ts new file mode 100644 index 0000000..e5fc161 --- /dev/null +++ b/src/lib/config/list-workspace-items.ts @@ -0,0 +1,26 @@ +/** + * Copyright 2023 Hasnae Rehioui + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { itemFilter, WithOptions } from '../item'; + +import { getConfiguration } from './get-configuration'; + +export const listWorkspaceItems = async (options: WithOptions) => { + const { registry, workspacesRoot, workspacesDefault } = + await getConfiguration(); + const workspace = options.workspace ?? workspacesDefault; + const items = await registry.list(itemFilter({ ...options, workspace })); + return { registry, workspacesRoot, items }; +}; diff --git a/src/lib/config/with-filter-options.ts b/src/lib/config/with-filter-options.ts new file mode 100644 index 0000000..bc1379a --- /dev/null +++ b/src/lib/config/with-filter-options.ts @@ -0,0 +1,24 @@ +/** + * Copyright 2023 Hasnae Rehioui + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Command } from 'commander'; + +export const withFilterOptions = (command: Command) => + command + .option('-w, --workspace ', 'with workspace') + .option('--ns, --namespace ', 'filter by namespace') + .option('-h, --host ', 'filter by host') + .option('-s, --slug ', 'filter by name or slug') + .option('-k, --keyword ', 'filter by keyword'); diff --git a/src/lib/index.ts b/src/lib/index.ts index 1252e11..d54ef44 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -13,12 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './from-input'; -export * from './get-configuration'; -export * from './git-raw-output-handler'; -export * from './item-filter'; -export * from './item-location'; -export * from './link-parser'; -export * from './with-program'; - -export * from './types'; +export * from './item'; +export * from './config'; +export * from './branches'; +export * from './worktrees'; diff --git a/src/lib/from-input.test.ts b/src/lib/item/from-input.test.ts similarity index 100% rename from src/lib/from-input.test.ts rename to src/lib/item/from-input.test.ts diff --git a/src/lib/from-input.ts b/src/lib/item/from-input.ts similarity index 100% rename from src/lib/from-input.ts rename to src/lib/item/from-input.ts diff --git a/src/lib/item/index.ts b/src/lib/item/index.ts new file mode 100644 index 0000000..69255bb --- /dev/null +++ b/src/lib/item/index.ts @@ -0,0 +1,21 @@ +/** + * Copyright 2023 Hasnae Rehioui + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './types'; +export * from './link-parser'; +export * from './from-input'; +export * from './item-location'; +export * from './item-filter'; +export * from './item-status'; diff --git a/src/lib/item-filter.test.ts b/src/lib/item/item-filter.test.ts similarity index 100% rename from src/lib/item-filter.test.ts rename to src/lib/item/item-filter.test.ts diff --git a/src/lib/item-filter.ts b/src/lib/item/item-filter.ts similarity index 100% rename from src/lib/item-filter.ts rename to src/lib/item/item-filter.ts diff --git a/src/lib/item-location.ts b/src/lib/item/item-location.ts similarity index 100% rename from src/lib/item-location.ts rename to src/lib/item/item-location.ts diff --git a/src/lib/item-status.test.ts b/src/lib/item/item-status.test.ts similarity index 100% rename from src/lib/item-status.test.ts rename to src/lib/item/item-status.test.ts diff --git a/src/lib/item-status.ts b/src/lib/item/item-status.ts similarity index 100% rename from src/lib/item-status.ts rename to src/lib/item/item-status.ts diff --git a/src/lib/link-parser.test.ts b/src/lib/item/link-parser.test.ts similarity index 100% rename from src/lib/link-parser.test.ts rename to src/lib/item/link-parser.test.ts diff --git a/src/lib/link-parser.ts b/src/lib/item/link-parser.ts similarity index 100% rename from src/lib/link-parser.ts rename to src/lib/item/link-parser.ts diff --git a/src/lib/types.ts b/src/lib/item/types.ts similarity index 100% rename from src/lib/types.ts rename to src/lib/item/types.ts diff --git a/src/lib/with-program.ts b/src/lib/with-program.ts deleted file mode 100644 index c3345c6..0000000 --- a/src/lib/with-program.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Copyright 2023 Hasnae Rehioui - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { Command } from 'commander'; - -import { Configuration } from './get-configuration'; - -export type WithProgram = ( - configuration: Configuration, - program: Command -) => void; - -export const withProgram = (fn: WithProgram) => { - return async (configuration: Configuration) => { - const program = new Command(); - program.option( - '-w, --workspace ', - 'with workspace', - configuration.workspacesDefault - ); - program.option('--ns, --namespace ', 'filter by namespace'); - program.option('-h, --host ', 'filter by host'); - program.option('-s, --slug ', 'filter by name or slug'); - program.option('-k, --keyword ', 'filter by keyword'); - fn(configuration, program); - const packageJson = await import('../../package.json'); - program.version(packageJson.version); - program.parse(process.argv); - }; -}; diff --git a/src/lib/worktrees/add-worktree.ts b/src/lib/worktrees/add-worktree.ts new file mode 100644 index 0000000..4eb09bd --- /dev/null +++ b/src/lib/worktrees/add-worktree.ts @@ -0,0 +1,31 @@ +/** + * Copyright 2023 Hasnae Rehioui + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as path from 'node:path'; + +import { simpleGit } from 'simple-git'; + +import { listWorktrees } from './list-worktrees'; + +export const addWorktree = async (name: string) => { + const worktrees = await listWorktrees(); + const main = worktrees.find((worktree) => worktree.main); + if (!main) return; + // Worktrees live in a sibling folder to the repo, named -worktrees. + const target = path.join(`${main.path}-worktrees`, name); + const output = await simpleGit().raw(['worktree', 'add', target]); + console.info(output); + return target; +}; diff --git a/src/lib/worktrees/index.ts b/src/lib/worktrees/index.ts new file mode 100644 index 0000000..84b5119 --- /dev/null +++ b/src/lib/worktrees/index.ts @@ -0,0 +1,18 @@ +/** + * Copyright 2023 Hasnae Rehioui + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './list-worktrees'; +export * from './add-worktree'; +export * from './select-and-remove-worktrees'; diff --git a/src/lib/worktrees/list-worktrees.test.ts b/src/lib/worktrees/list-worktrees.test.ts new file mode 100644 index 0000000..4744f07 --- /dev/null +++ b/src/lib/worktrees/list-worktrees.test.ts @@ -0,0 +1,50 @@ +/** + * Copyright 2023 Hasnae Rehioui + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { listWorktrees } from './list-worktrees'; + +const porcelain = [ + 'worktree /repo', + 'HEAD abc123', + 'branch refs/heads/main', + '', + 'worktree /repo-worktrees/feature', + 'HEAD def456', + 'branch refs/heads/feature', + '' +].join('\n'); + +jest.mock('simple-git', () => { + return { + simpleGit: jest.fn(() => ({ + raw: jest.fn(() => Promise.resolve(porcelain)) + })) + }; +}); + +describe('list-worktrees', () => { + it('should parse the porcelain output and mark the main worktree', async () => { + const worktrees = await listWorktrees(); + expect(worktrees).toEqual([ + { path: '/repo', head: 'abc123', branch: 'main', main: true }, + { + path: '/repo-worktrees/feature', + head: 'def456', + branch: 'feature', + main: false + } + ]); + }); +}); diff --git a/src/lib/worktrees/list-worktrees.ts b/src/lib/worktrees/list-worktrees.ts new file mode 100644 index 0000000..210d8b2 --- /dev/null +++ b/src/lib/worktrees/list-worktrees.ts @@ -0,0 +1,57 @@ +/** + * Copyright 2023 Hasnae Rehioui + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { simpleGit } from 'simple-git'; + +export type Worktree = { + path: string; + head?: string; + branch?: string; + // The first worktree reported by git is always the main one and cannot be + // removed. + main: boolean; +}; + +export const listWorktrees = async (): Promise => { + const output = await simpleGit().raw(['worktree', 'list', '--porcelain']); + const worktrees: Worktree[] = []; + let current: Partial | undefined; + for (const line of output.split('\n')) { + if (line.startsWith('worktree ')) { + current = { path: line.slice('worktree '.length) }; + } else if (!current) { + continue; + } else if (line.startsWith('HEAD ')) { + current.head = line.slice('HEAD '.length); + } else if (line.startsWith('branch ')) { + current.branch = line + .slice('branch '.length) + .replace(/^refs\/heads\//, ''); + } else if (line.trim() === '') { + worktrees.push({ + ...current, + main: worktrees.length === 0 + } as Worktree); + current = undefined; + } + } + if (current) { + worktrees.push({ + ...current, + main: worktrees.length === 0 + } as Worktree); + } + return worktrees; +}; diff --git a/src/lib/worktrees/select-and-remove-worktrees.test.ts b/src/lib/worktrees/select-and-remove-worktrees.test.ts new file mode 100644 index 0000000..bd29870 --- /dev/null +++ b/src/lib/worktrees/select-and-remove-worktrees.test.ts @@ -0,0 +1,48 @@ +/** + * Copyright 2023 Hasnae Rehioui + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { selectAndRemoveWorktrees } from './select-and-remove-worktrees'; + +jest.mock('inquirer', () => { + return { + prompt: jest.fn(() => { + return Promise.resolve({ + selectedPaths: ['/repo-worktrees/feature'] + }); + }) + }; +}); + +jest.mock('simple-git', () => { + return { + simpleGit: jest.fn(() => ({ + raw: jest.fn(() => { + return Promise.resolve('DONE'); + }) + })) + }; +}); + +jest.spyOn(console, 'table'); + +describe('select-and-remove-worktrees', () => { + it('should select and remove worktrees', async () => { + await selectAndRemoveWorktrees([ + { path: '/repo', main: true }, + { path: '/repo-worktrees/feature', branch: 'feature', main: false } + ]); + expect(jest.mocked(console.table).mock.calls).toHaveLength(1); + }); +}); diff --git a/src/lib/worktrees/select-and-remove-worktrees.ts b/src/lib/worktrees/select-and-remove-worktrees.ts new file mode 100644 index 0000000..7e7872f --- /dev/null +++ b/src/lib/worktrees/select-and-remove-worktrees.ts @@ -0,0 +1,54 @@ +/** + * Copyright 2023 Hasnae Rehioui + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import inquirer from 'inquirer'; +import { simpleGit } from 'simple-git'; + +import { Worktree } from './list-worktrees'; + +const selectForRemoveQuestion = async (worktrees: Worktree[]) => { + // The main worktree cannot be removed, so it is not offered as a choice. + const removable = worktrees.filter((worktree) => !worktree.main); + if (removable.length === 0) return undefined; + return await inquirer.prompt([ + { + name: 'selectedPaths', + type: 'checkbox', + message: 'choose worktrees to remove', + choices: removable.map((worktree) => ({ + name: worktree.branch + ? `${worktree.path} (${worktree.branch})` + : worktree.path, + value: worktree.path + })), + pageSize: 10 + } + ]); +}; + +const selectForRemoveAnswer = async (answer?: { selectedPaths: string[] }) => { + if (!answer || answer.selectedPaths.length === 0) return; + await Promise.all( + answer.selectedPaths.map((target) => + simpleGit().raw(['worktree', 'remove', target]) + ) + ); + // `git worktree remove` is silent on success, so report what was removed. + console.table(answer.selectedPaths.map((path) => ({ removed: path }))); +}; + +export const selectAndRemoveWorktrees = async (worktrees: Worktree[]) => { + return selectForRemoveQuestion(worktrees).then(selectForRemoveAnswer); +}; diff --git a/src/tasks/index.ts b/src/tasks/index.ts index 856cc2d..3a63fb6 100644 --- a/src/tasks/index.ts +++ b/src/tasks/index.ts @@ -15,3 +15,4 @@ */ export * from './clone-item-task'; export * from './sync-item-task'; +export * from './submit-item-tasks'; diff --git a/src/bin/git-workspace-remove.ts b/src/tasks/submit-item-tasks.ts similarity index 50% rename from src/bin/git-workspace-remove.ts rename to src/tasks/submit-item-tasks.ts index c79250c..4f43d05 100644 --- a/src/bin/git-workspace-remove.ts +++ b/src/tasks/submit-item-tasks.ts @@ -1,5 +1,3 @@ -#! /usr/bin/env node - /** * Copyright 2023 Hasnae Rehioui * @@ -15,16 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { getConfiguration, itemFilter, withProgram, WithProgram } from '../lib'; +import { + CliProgressRunContext, + Runnable, + withCliProgress +} from '@labset/task-pool-executor'; -const removeRepos: WithProgram = ({ registry }, program) => { - program.description('remove workspace repos').action(async (opts) => { - const { workspace, namespace, host, slug, keyword } = opts; - const targets = await registry.list( - itemFilter({ workspace, namespace, host, slug, keyword }) - ); - await Promise.all(targets.map((target) => registry.remove(target.id))); - }); -}; +import { Item } from '../lib'; -getConfiguration().then(withProgram(removeRepos)).catch(console.error); +export const submitItemTasks = ( + items: Item[], + task: (item: Item) => Runnable | undefined +) => + withCliProgress((taskPool) => { + items + .map(task) + .forEach((runnable) => runnable && taskPool.submit(runnable)); + });