diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..238f1bd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + - name: Install dprint + run: | + curl -fsSL https://dprint.dev/install.sh | sh + echo "$HOME/.dprint/bin" >> "$GITHUB_PATH" + - name: dprint check + run: dprint check + - name: deno lint + run: deno lint + - name: deno check + run: deno check scripts/*.ts scripts/lib/*.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6cceb83 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,16 @@ +# Contributing + +## Workflow + +Format and check before committing: + +```bash +deno task fmt +deno task check +``` + +## Conventions + +- Hook scripts under `scripts/` are standalone CLIs (no exports). +- Shared code lives in `scripts/lib/`. +- Commits follow [Conventional Commits](https://www.conventionalcommits.org/). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..896c374 --- /dev/null +++ b/LICENSE @@ -0,0 +1,203 @@ +Copyright (c) 2026 Ryan Lee (systemfsoftware) + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Ryan Lee (systemfsoftware) + + 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. diff --git a/README.md b/README.md index e4f6755..90177a3 100644 --- a/README.md +++ b/README.md @@ -1 +1,57 @@ # worktrunk-config + +> Deno worktree hooks for `worktrunk` automating environment setup, warm-copy indexing, and path isolation. + +When creating isolated git worktrees, new checkouts often start cold: index databases must be rebuilt from scratch, nested `.git` references break across directory boundaries, and development dependencies require manual setup. This repository provides executable TypeScript hooks that wire into `worktrunk` lifecycle events (`pre-start`, `post-start`, `post-switch`, `pre-merge`) to make every worktree immediately ready for development. + +```toml +# .config/wt.toml +[hooks] +pre-start = "deno run --allow-read --allow-write --allow-run --allow-env ./scripts/pre-start.ts {{worktree_path}} {{primary_worktree_path}}" +post-start = "deno run --allow-read --allow-write --allow-run --allow-env ./scripts/copy-codegraph.ts {{worktree_path}} {{primary_worktree_path}}" +post-switch = "deno run --allow-run ./scripts/post-switch.ts {{worktree_path}}" +pre-merge = "deno run --allow-read --allow-write --allow-run ./scripts/pre-merge.ts {{worktree_path}}" +``` + +## Quick Start + +Ensure [Deno](https://deno.land/) is installed on your workstation, then verify the codebase: + +```bash +git clone https://github.com/systemfsoftware/worktrunk-scripts.git +cd worktrunk-scripts +deno task check +``` + +The hooks execute directly with `deno run` using explicit permission flags declared in each script's shebang. + +## Design + +Scripts under `scripts/` are standalone CLI entrypoints invoked by `worktrunk` lifecycle events. Shared helpers (git resolution, relative path mapping, filesystem utilities) live under `scripts/lib/`. + +## Configuration + +In your primary repository, configure `worktrunk` to call the desired hook scripts in `.config/wt.toml`. Every script accepts standard arguments supplied by the runner: + +```bash +deno run --allow-read --allow-write --allow-run --allow-env ./scripts/.ts [primary_path] +``` + +If the secondary `primary_path` argument is omitted, the hook automatically resolves the primary repository root using the shared git common directory. + +## Quality Gates + +Code formatting and linting are enforced via `dprint` and `deno lint`: + +```bash +deno task fmt # Format all files using dprint +deno task check # Run dprint verification and deno lint +``` + +## Contributing + +Development setup, conventions, and workflow: [CONTRIBUTING.md](CONTRIBUTING.md). + +## License + +See [LICENSE](LICENSE) for terms. diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..b502322 --- /dev/null +++ b/deno.json @@ -0,0 +1,21 @@ +{ + "description": "Worktrunk worktree hooks", + "tasks": { + "check": "dprint check && deno lint && deno check scripts/*.ts scripts/lib/*.ts", + "lint": "deno lint", + "fmt": "dprint fmt", + "fmt:check": "dprint check", + "test": "deno test --allow-read --allow-write --allow-run --allow-env" + }, + "imports": { + "@std/assert": "jsr:@std/assert@^1.0.14", + "@std/fs": "jsr:@std/fs@^1.0.19", + "@std/path": "jsr:@std/path@^1.0.9" + }, + "lint": { + "rules": { + "exclude": ["no-slow-types"] + } + }, + "exclude": [".codegraph", ".git"] +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..94e615a --- /dev/null +++ b/deno.lock @@ -0,0 +1,42 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/assert@^1.0.14": "1.0.19", + "jsr:@std/fs@^1.0.19": "1.0.24", + "jsr:@std/internal@^1.0.12": "1.0.14", + "jsr:@std/internal@^1.0.14": "1.0.14", + "jsr:@std/path@^1.0.9": "1.1.6", + "jsr:@std/path@^1.1.5": "1.1.6" + }, + "jsr": { + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal@^1.0.12" + ] + }, + "@std/fs@1.0.24": { + "integrity": "f3061b45b81673a2bece689da041df32d174be064c89eb6397fb5718d3fb7877", + "dependencies": [ + "jsr:@std/internal@^1.0.14", + "jsr:@std/path@^1.1.5" + ] + }, + "@std/internal@1.0.14": { + "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" + }, + "@std/path@1.1.6": { + "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", + "dependencies": [ + "jsr:@std/internal@^1.0.14" + ] + } + }, + "workspace": { + "dependencies": [ + "jsr:@std/assert@^1.0.14", + "jsr:@std/fs@^1.0.19", + "jsr:@std/path@^1.0.9" + ] + } +} diff --git a/docs/plans/2026-08-30-deno-worktrunk-conversion.md b/docs/plans/2026-08-30-deno-worktrunk-conversion.md new file mode 100644 index 0000000..35a83ac --- /dev/null +++ b/docs/plans/2026-08-30-deno-worktrunk-conversion.md @@ -0,0 +1,108 @@ +--- +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +execution: code +title: Deno project setup + worktrunk scripts conversion +created: 2026-08-30 +--- + +# Deno Project + Worktrunk Scripts Conversion + +## Objective + +Initialize this repo as a Deno project and port the 10 bash hook scripts from +`https://github.com/systemfsoftware/systemfsoftware/tree/main/scripts/tools/worktrunk` to Deno +TypeScript, preserving behavior for `wt.toml` integration. + +Wiki query ran: `deno project setup deno.json conventions`, +`converting bash shell scripts to Deno TypeScript worktrunk hooks`, +`worktrunk wt.toml hook scripts Deno vs bash implementation` against software-wiki (7 results, none +directly on Deno project conventions; closest #32318f module-resolution). Web-equivalent: local +`deno --version` 2.9.5 verified; primary Deno docs would be fetched via ctx7 if needed during +implementation. + +## Context + +- Repo `worktrunk-config` is empty (no commits, only `.git` + remote `origin`). +- Source: 10 files in `systemfsoftware/systemfsoftware/scripts/tools/worktrunk` (listed via GitHub + API 2026-08-30): + - `lib.sh`, `pre-start.sh`, `copy-codegraph.sh`, `codegraph-worktree-mcp.sh`, + `convert-to-relative-paths.sh`, `generate-artifacts.sh`, `install-deps.sh`, `post-switch.sh`, + `pre-merge.sh`, `worktree-to-relative.sh` +- Requirements: 1) `deno.json` project setup, 2) copy+convert scripts to Deno. + +## Requirements + +### Functional + +- R1: `deno.json` exists at repo root with tasks, fmt/lint config, imports for `@std/*` as needed. +- R2: All 10 bash scripts have Deno equivalents under `scripts/tools/worktrunk/` (or + `scripts/worktrunk/`) as `.ts` files with `#!/usr/bin/env -S deno run --allow-*` shebangs, exact + allow scopes, and no `--allow-all`. +- R3: Each Deno script preserves observable behavior: gitdir relative conversion, shared dir + symlinking, codegraph warm-copy fallback chain (sqlite .backup → reflink → cp), MCP provisioning, + dep detection, artifact generation, pre-merge cleanup, post-switch config unset, bulk conversion. +- R4: `wt.toml` / `.config/wt.toml` updated to invoke `deno run` scripts (or shim) if required for + hook integration. + +### Non-functional + +- N1: `deno check`, `deno lint`, `deno fmt --check` pass. +- N2: Executable permissions preserved; async I/O awaited, no `*Sync` in async paths. + +## Units + +### U1: Deno project scaffold + +- Files: `deno.json`, `deno.lock` (after `deno install`/`deno cache`), `.gitignore` entry for + `.deno/` if needed. +- Tasks: `deno task` entries for `check`, `lint`, `fmt`, `test` as applicable. +- Verification: `deno --version` ok, `deno task check` runs `deno check`/`lint`/`fmt`. + +### U2: Shared lib.ts (port of lib.sh) + +- Source `lib.sh: resolve_primary_repo` → + `lib.ts: resolvePrimaryRepo(worktreeRoot: string): Promise` using `Deno.Command` + `git rev-parse`. +- Verification: unit test or manual `deno run` with temp worktree fixture. + +### U3: pre-start.ts, post-switch.ts, pre-merge.ts, convert-to-relative-paths.ts, worktree-to-relative.ts + +- Port path-conversion logic (`realpath --relative-to` → `std/path` relative + `Deno.realPath`), + gitdir file I/O, symlink handling. +- Keep idempotency. +- Verification: run each with fixture dirs, assert file contents. + +### U4: copy-codegraph.ts + codegraph-worktree-mcp.ts + +- Port warm-copy chain, integrity check via `sqlite3` if present, `cp --reflink` fallback via + `Deno.Command`. +- MCP provisioning: `Deno.Command` for codegraph CLI, Python-like JSON merge rewritten in TS via + `@std/json`/`Deno.readTextFile`. +- Verification: dry-run with mocked `codegraph`/`sqlite3` not present paths covered. + +### U5: install-deps.ts + generate-artifacts.ts + +- Detect lockfiles (`pnpm-lock.yaml`, `package-lock.json`, etc.) and run equivalent `Deno.Command` + with correct args. +- Verification: `deno check` type-checks; manual invocation in repo with no lockfile → skips. + +## Verification + +- `deno check scripts/tools/worktrunk/*.ts` +- `deno lint` +- `deno fmt --check` +- Manual smoke: + `deno run --allow-read --allow-run --allow-write scripts/tools/worktrunk/pre-start.ts --help` or + with temp dir. +- Ensure `git ls-files` shows new `.ts` files, no `.sh` left unless shim retained. + +## Risks + +- `realpath --relative-to` semantics differ on missing paths → handle via `std/path.relative`. +- Worktree detection depends on `git rev-parse` output normalization. +- `codegraph` binary absent → must be best-effort, not failure. + +## Out of Scope + +- Publishing to JSR, CI workflow changes beyond basic `deno task` verification. diff --git a/docs/solutions/tooling/bash-to-deno-port-semantics.md b/docs/solutions/tooling/bash-to-deno-port-semantics.md new file mode 100644 index 0000000..28997b2 --- /dev/null +++ b/docs/solutions/tooling/bash-to-deno-port-semantics.md @@ -0,0 +1,54 @@ +--- +title: Bash-to-Deno hook ports must preserve set -e and expansion semantics +date: 2026-08-30 +category: tooling +module: worktrunk hooks +problem_type: best_practice +component: tooling +severity: high +applies_when: + - Porting bash lifecycle hooks to Deno TypeScript + - Verifying a port preserves observable behavior against the original +tags: [bash-port, set-e, exit-code, glob, deno] +--- + +# Bash-to-Deno hook ports must preserve set -e and expansion semantics + +## Context + +Porting the 10 bash worktrunk hooks to Deno exposed three silent-behavior gaps that a line-by-line port misses: child exit codes, shell glob semantics, and variable expansion. Each one shipped green and broke the bash contract. + +## Guidance + +- **Propagate every child exit code.** Bash `set -e` aborts the hook when `npm ci` or `pnpm build` fails. The Deno port must `Deno.exit(code)` on non-zero child output, not log-and-continue: + +```ts +const { code } = await cmd.output() +if (code !== 0) { + console.error(`build exited ${code}`) + Deno.exit(code) +} +``` + +- **Do not translate a shell glob to a regex by escaping metacharacters.** Escaping `[` breaks character classes — `[0-9]*-*.md` became `^\[0-9\].*-.*\.md$` and matched nothing. Write the regex from the glob's matched set, or test the port against `shopt -s nullglob` expansion. +- **Bash substitution and JS regex collapse are NOT equivalent.** Bash `${x//--/-}` is one non-overlapping pass: `a---b` -> `a--b`. JS `/--+/g` fully collapses: `a-b`. Port instance/name sanitization with the same pass semantics (`/--/g`), or the derived socket paths diverge. +- **Check template variables against the caller's docs.** wt.toml exposes `{{ primary_worktree_path }}`, not `{{ primary_path }}`; the undefined variable aborts hook expansion at runtime. +- **Verify symlink-removal semantics empirically per runtime.** On Deno 2.9, `Deno.remove` on a symlink removes the link and keeps the target (refutes denoland/deno#1947 claims of target deletion). Do not trust issue-tracker claims over a one-line runtime test. + +## Why This Matters + +Hooks run as worktree lifecycle callbacks; a hook that exits 0 after a failed install lets the merge proceed with broken state and misattributes later failures. Divergent instance names produce dead MCP sockets that fail confusingly. + +## When to Apply + +- Any bash-to-TS/Deno port reviewed for behavioral parity. +- Hooks whose failure must block the caller (pre-merge, post-start install/build). + +## Examples + +- `install-deps.ts` / `generate-artifacts.ts` now exit non-zero when the child command fails (restores `set -e` aborts). +- `codegraph-worktree-mcp.ts` sanitization matches bash's non-overlapping dash collapse. + +## Related + +- docs/plans/2026-08-30-deno-worktrunk-conversion.md diff --git a/dprint.json b/dprint.json new file mode 100644 index 0000000..012b70a --- /dev/null +++ b/dprint.json @@ -0,0 +1,31 @@ +{ + "lineWidth": 120, + "typescript": { + "semiColons": "asi", + "quoteStyle": "preferSingle", + "trailingCommas": "onlyMultiLine", + "binaryExpression.operatorPosition": "sameLine" + }, + "json": {}, + "markdown": {}, + "toml": {}, + "yaml": {}, + "excludes": [ + "**/node_modules", + "**/dist", + "**/*-lock.json", + ".turbo", + ".worktrees/**", + "pnpm-lock.yaml", + "**/etc/*.api.md", + "vendor/**", + "repos/**" + ], + "plugins": [ + "https://plugins.dprint.dev/typescript-0.96.1.wasm", + "https://plugins.dprint.dev/json-0.19.3.wasm", + "https://plugins.dprint.dev/markdown-0.17.8.wasm", + "https://plugins.dprint.dev/toml-0.6.2.wasm", + "https://plugins.dprint.dev/g-plane/pretty_yaml-v0.5.0.wasm" + ] +} diff --git a/scripts/codegraph-worktree-mcp.ts b/scripts/codegraph-worktree-mcp.ts new file mode 100755 index 0000000..5d3c420 --- /dev/null +++ b/scripts/codegraph-worktree-mcp.ts @@ -0,0 +1,89 @@ +#!/usr/bin/env -S deno run --allow-read --allow-write --allow-run --allow-env + +function sanitizeInstance(name: string): string { + let s = name.replace(/[^A-Za-z0-9_.-]/g, '-').replace(/--/g, '-') + s = s.replace(/^[-_.]+/, '').replace(/[-_.]+$/, '') + return s || 'root' +} + +async function commandExists(cmd: string): Promise { + const c = new Deno.Command('which', { args: [cmd], stdout: 'null', stderr: 'null' }) + const { code } = await c.output() + return code === 0 +} + +async function provisionMcp(worktreePath: string): Promise { + try { + await Deno.stat(worktreePath) + } catch { + console.error(`codegraph-worktree-mcp: no such dir: ${worktreePath}`) + Deno.exit(1) + } + + let cg = '' + if (await commandExists('codegraph')) cg = 'codegraph' + else if (Deno.env.get('HOME')) { + const p = `${Deno.env.get('HOME')}/.local/bin/codegraph` + try { + await Deno.stat(p) + cg = p + } catch { /* not found */ } + } + if (!cg) { + console.error( + 'codegraph-worktree-mcp: codegraph CLI not found on PATH or at $HOME/.local/bin/codegraph — install omp-infra-bootstrap first', + ) + Deno.exit(1) + } + + console.log(`codegraph-worktree-mcp: ensuring instance for ${worktreePath}`) + { + const cmd = new Deno.Command(cg, { + args: [], + cwd: worktreePath, + stdout: 'null', + stderr: 'null', + }) + const { code } = await cmd.output() + if (code !== 0) { + console.error('codegraph-worktree-mcp: instance install failed') + Deno.exit(1) + } + } + + const base = worktreePath.replace(/\/+$/, '').split('/').at(-1) ?? 'root' + const instance = sanitizeInstance(base) + const socket = `${ + Deno.env.get('HOME') + }/.local/share/containers/storage/volumes/codegraph-${instance}-data/_data/codegraph.sock` + const mcpFile = `${worktreePath}/.mcp.json` + + let cfg: Record = {} + try { + const raw = await Deno.readTextFile(mcpFile) + cfg = JSON.parse(raw) + } catch (e) { + if (e instanceof SyntaxError) { + console.error(`codegraph-worktree-mcp: existing ${mcpFile} unreadable (${e}); overwriting`) + cfg = {} + } else if (!(e instanceof Deno.errors.NotFound)) { + // other IO error, overwrite + cfg = {} + } + } + const servers = (cfg['mcpServers'] as Record | undefined) ?? {} + servers['codegraph'] = { + type: 'stdio', + command: 'socat', + args: ['STDIO', `UNIX-CONNECT:${socket}`], + enabled: true, + } + cfg['mcpServers'] = servers + await Deno.writeTextFile(mcpFile, JSON.stringify(cfg, null, 2) + '\n') + console.log(`codegraph-worktree-mcp: ${mcpFile} -> ${socket}`) +} + +if (import.meta.main) { + const wt = Deno.args[0] ?? Deno.cwd() + await provisionMcp(wt) +} diff --git a/scripts/convert-to-relative-paths.ts b/scripts/convert-to-relative-paths.ts new file mode 100755 index 0000000..7702558 --- /dev/null +++ b/scripts/convert-to-relative-paths.ts @@ -0,0 +1,44 @@ +#!/usr/bin/env -S deno run --allow-read --allow-write +import { relative } from '@std/path' + +async function convertToRelativePaths(sharedRoot: string): Promise { + let count = 0 + for await (const entry of Deno.readDir(sharedRoot)) { + if (!entry.isDirectory) continue + const gitFile = `${sharedRoot}/${entry.name}/.git` + let stat: Deno.FileInfo + try { + stat = await Deno.stat(gitFile) + } catch { + continue + } + if (!stat.isFile) continue + const line = (await Deno.readTextFile(gitFile)).split('\n')[0] ?? '' + if (!line.startsWith('gitdir:')) continue + const absPath = line.slice('gitdir:'.length).trim() + if (!absPath.startsWith('/')) continue + const worktreePath = `${sharedRoot}/${entry.name}` + let rel: string + try { + rel = relative(worktreePath, absPath) + } catch { + continue + } + await Deno.writeTextFile(gitFile, `gitdir: ${rel}\n`) + console.log(` ${worktreePath}/.git -> ${rel}`) + count++ + } + console.log(`converted ${count} worktree .git files`) + return count +} + +if (import.meta.main) { + const root = Deno.args[0] ?? Deno.cwd() + try { + await Deno.stat(root) + } catch { + console.error(`error: ${root} is not a directory`) + Deno.exit(1) + } + await convertToRelativePaths(root) +} diff --git a/scripts/copy-codegraph.ts b/scripts/copy-codegraph.ts new file mode 100755 index 0000000..85504e5 --- /dev/null +++ b/scripts/copy-codegraph.ts @@ -0,0 +1,231 @@ +#!/usr/bin/env -S deno run --allow-read --allow-write --allow-run --allow-env +import { resolvePrimaryRepo } from './lib/git.ts' + +async function commandExists(cmd: string): Promise { + try { + const c = new Deno.Command('which', { args: [cmd], stdout: 'null', stderr: 'null' }) + const { code } = await c.output() + return code === 0 + } catch { + return false + } +} + +async function runCodegraphMcp(worktreePath: string): Promise { + const script = new URL('./codegraph-worktree-mcp.ts', import.meta.url).pathname + const cmd = new Deno.Command(Deno.execPath(), { + args: [ + 'run', + '--allow-read', + '--allow-write', + '--allow-run', + '--allow-env', + script, + worktreePath, + ], + stdout: 'piped', + stderr: 'piped', + }) + const { code, stderr } = await cmd.output() + if (code !== 0) { + const msg = new TextDecoder().decode(stderr).trim() + console.log(`copy-codegraph: codegraph MCP provisioning skipped (rc=${code}) ${msg}`) + } +} + +async function trySqliteBackup(src: string, dst: string): Promise { + if (!await commandExists('sqlite3')) return false + const cmd = new Deno.Command('sqlite3', { + args: [src, `.backup '${dst}'`], + stdout: 'null', + stderr: 'null', + }) + const { code } = await cmd.output() + if (code !== 0) return false + try { + const s = await Deno.stat(dst) + return s.size > 0 + } catch { + return false + } +} + +async function tryReflink(src: string, dst: string): Promise { + const cmd = new Deno.Command('cp', { + args: ['--reflink=always', src, dst], + stdout: 'null', + stderr: 'null', + }) + const { code } = await cmd.output() + return code === 0 +} + +async function tryCopy(src: string, dst: string): Promise { + try { + await Deno.copyFile(src, dst) + return true + } catch { + return false + } +} + +async function isSqliteOk(path: string): Promise { + if (!await commandExists('sqlite3')) return true // daemon will rebuild if corrupt + const cmd = new Deno.Command('sqlite3', { + args: [path, 'PRAGMA quick_check;'], + stdout: 'piped', + stderr: 'null', + }) + const { stdout } = await cmd.output() + const out = new TextDecoder().decode(stdout) + return out.includes('ok') +} + +async function copyCodegraph(worktreePath: string, primaryPath: string | null): Promise { + await runCodegraphMcp(worktreePath) + + const srcDb = primaryPath ? `${primaryPath}/.codegraph/codegraph.db` : '' + const dstDir = `${worktreePath}/.codegraph` + const dstDb = `${dstDir}/codegraph.db` + + if (!primaryPath || primaryPath === worktreePath) { + console.log( + 'copy-codegraph: no separate primary worktree, skipping warm copy (codegraph init will build fresh)', + ) + } else { + let srcExists = false + try { + const s = await Deno.stat(srcDb) + srcExists = s.size > 0 + } catch { + srcExists = false + } + if (!srcExists) { + console.log( + `copy-codegraph: no primary index at ${srcDb}, skipping warm copy (codegraph init will build fresh)`, + ) + } else { + let dstExists = false + try { + const s = await Deno.stat(dstDb) + dstExists = s.size > 0 + } catch { + dstExists = false + } + if (dstExists) { + console.log('copy-codegraph: worktree already has an index, skipping warm copy') + } else { + await Deno.mkdir(dstDir, { recursive: true }) + // sweep partials + for await (const e of Deno.readDir(dstDir)) { + if (e.name.startsWith('codegraph.db.partial.')) { + try { + await Deno.remove(`${dstDir}/${e.name}`) + } catch { /* ignore */ } + } + } + const tmpDb = `${dstDb}.partial.${Deno.pid}` + let mechanism = '' + // try sqlite backup + if (await trySqliteBackup(srcDb, tmpDb)) { + mechanism = 'sqlite backup, consistent snapshot' + } else { + try { + await Deno.remove(tmpDb) + } catch { /* ignore */ } + if (await tryReflink(srcDb, tmpDb)) { + mechanism = 'reflink, no data moved' + if (!await isSqliteOk(tmpDb)) { + console.log( + 'copy-codegraph: integrity check failed on copied index, dropping it (codegraph init will rebuild fresh)', + ) + try { + await Deno.remove(tmpDb) + } catch { /* ignore */ } + mechanism = '' + } + } else { + try { + await Deno.remove(tmpDb) + } catch { /* ignore */ } + if (await tryCopy(srcDb, tmpDb)) { + mechanism = 'full byte copy, no reflink on this filesystem' + if (!await isSqliteOk(tmpDb)) { + console.log( + 'copy-codegraph: integrity check failed on copied index, dropping it (full init will rebuild)', + ) + try { + await Deno.remove(tmpDb) + } catch { /* ignore */ } + mechanism = '' + } + } else { + console.log( + 'copy-codegraph: every copy mechanism failed, skipping DB copy (codegraph init will build fresh)', + ) + } + } + } + if (mechanism) { + try { + const s = await Deno.stat(tmpDb) + if (s.size > 0) { + await Deno.rename(tmpDb, dstDb) + console.log(`copy-codegraph: index warm-started (${mechanism})`) + } + } catch { + if (mechanism) { + console.log( + 'copy-codegraph: index copied but rename failed, dropping it (codegraph init will build fresh)', + ) + } + try { + await Deno.remove(tmpDb) + } catch { /* ignore */ } + } + } + } + } + } + + // codegraph init + let cgBin: string | null = null + if (await commandExists('codegraph')) cgBin = 'codegraph' + else { + try { + await Deno.stat(`${Deno.env.get('HOME') ?? ''}/.local/bin/codegraph`) + cgBin = `${Deno.env.get('HOME')}/.local/bin/codegraph` + } catch { /* not found */ } + } + if (cgBin) { + const cmd = new Deno.Command(cgBin, { + args: ['init'], + cwd: worktreePath, + stdout: 'null', + stderr: 'null', + }) + const { code } = await cmd.output() + if (code !== 0) { + console.log(`copy-codegraph: codegraph init failed (rc=${code}) — index left to the daemon`) + } + } else { + console.log('copy-codegraph: codegraph CLI not found, skipping init (daemon will index fresh)') + } +} + +if (import.meta.main) { + const worktreePath = Deno.args[0] + if (!worktreePath) { + console.error('worktree_path required') + Deno.exit(1) + } + let primary: string | null = Deno.args[1] ?? null + if (!primary) { + try { + primary = await resolvePrimaryRepo(worktreePath) + } catch { + primary = null + } + } + await copyCodegraph(worktreePath, primary) +} diff --git a/scripts/generate-artifacts.ts b/scripts/generate-artifacts.ts new file mode 100755 index 0000000..7e186df --- /dev/null +++ b/scripts/generate-artifacts.ts @@ -0,0 +1,39 @@ +#!/usr/bin/env -S deno run --allow-read --allow-run +import { exists } from '@std/fs' + +async function generateArtifacts(worktreePath: string): Promise { + console.log('generate-artifacts: generating build artifacts...') + let cmd: Deno.Command + if (await exists(`${worktreePath}/pnpm-lock.yaml`)) { + cmd = new Deno.Command('corepack', { args: ['pnpm', 'build'], cwd: worktreePath }) + } else if (await exists(`${worktreePath}/package-lock.json`)) { + cmd = new Deno.Command('npm', { args: ['run', 'build'], cwd: worktreePath }) + } else if (await exists(`${worktreePath}/yarn.lock`)) { + cmd = new Deno.Command('yarn', { args: ['build'], cwd: worktreePath }) + } else if (await exists(`${worktreePath}/bun.lock`)) { + cmd = new Deno.Command('bun', { args: ['run', 'build'], cwd: worktreePath }) + } else if (await exists(`${worktreePath}/Cargo.toml`)) { + cmd = new Deno.Command('cargo', { args: ['build'], cwd: worktreePath }) + } else if (await exists(`${worktreePath}/go.mod`)) { + cmd = new Deno.Command('go', { args: ['build', './...'], cwd: worktreePath }) + } else { + console.log('generate-artifacts: no recognized build system, skipping') + console.log('generate-artifacts: done') + return + } + const { code } = await cmd.output() + if (code !== 0) { + console.error(`generate-artifacts: build exited ${code}`) + Deno.exit(code) + } + console.log('generate-artifacts: done') +} + +if (import.meta.main) { + const p = Deno.args[0] + if (!p) { + console.error('worktree_path required') + Deno.exit(1) + } + await generateArtifacts(p!) +} diff --git a/scripts/install-deps.ts b/scripts/install-deps.ts new file mode 100755 index 0000000..8fb49cb --- /dev/null +++ b/scripts/install-deps.ts @@ -0,0 +1,78 @@ +#!/usr/bin/env -S deno run --allow-read --allow-run --allow-env +import { exists } from '@std/fs' + +type Manager = { check: string; cmd: string[] } + +async function pickManager(worktreePath: string): Promise { + if (await exists(`${worktreePath}/pnpm-lock.yaml`)) { + return { check: 'pnpm-lock.yaml', cmd: ['corepack', 'pnpm', 'install', '--frozen-lockfile'] } + } + if (await exists(`${worktreePath}/package-lock.json`)) { + return { check: 'package-lock.json', cmd: ['npm', 'ci'] } + } + if (await exists(`${worktreePath}/yarn.lock`)) { + return { check: 'yarn.lock', cmd: ['yarn', 'install', '--frozen-lockfile'] } + } + if (await exists(`${worktreePath}/bun.lock`)) { + return { check: 'bun.lock', cmd: ['bun', 'install', '--frozen-lockfile'] } + } + if (await exists(`${worktreePath}/Cargo.toml`)) { + return { check: 'Cargo.toml', cmd: ['cargo', 'build'] } + } + if (await exists(`${worktreePath}/go.mod`)) { + return { check: 'go.mod', cmd: ['go', 'mod', 'download'] } + } + if (await exists(`${worktreePath}/Gemfile`)) { + return { check: 'Gemfile', cmd: ['bundle', 'install'] } + } + if ( + (await exists(`${worktreePath}/pyproject.toml`)) || + (await exists(`${worktreePath}/requirements.txt`)) + ) { + return { check: 'pyproject/requirements', cmd: ['pip-install'] } + } + return null +} + +async function installDeps(worktreePath: string): Promise { + console.log('install-deps: installing dependencies in worktree...') + const mgr = await pickManager(worktreePath) + if (!mgr) { + console.log('install-deps: no recognized package manager, skipping') + console.log('install-deps: done') + return + } + if (mgr.cmd[0] === 'pip-install') { + let cmd = new Deno.Command('pip', { args: ['install', '-e', '.'], cwd: worktreePath }) + let { code } = await cmd.output() + if (code !== 0) { + cmd = new Deno.Command('pip', { + args: ['install', '-r', 'requirements.txt'], + cwd: worktreePath, + }) + ;({ code } = await cmd.output()) + if (code !== 0) { + console.error(`install-deps: pip install exited ${code}`) + Deno.exit(code) + } + } + console.log('install-deps: done') + return + } + const cmd = new Deno.Command(mgr.cmd[0], { args: mgr.cmd.slice(1), cwd: worktreePath }) + const { code } = await cmd.output() + if (code !== 0) { + console.error(`install-deps: ${mgr.cmd.join(' ')} exited ${code}`) + Deno.exit(code) + } + console.log('install-deps: done') +} + +if (import.meta.main) { + const p = Deno.args[0] + if (!p) { + console.error('worktree_path required') + Deno.exit(1) + } + await installDeps(p!) +} diff --git a/scripts/lib/fs.ts b/scripts/lib/fs.ts new file mode 100644 index 0000000..d2138da --- /dev/null +++ b/scripts/lib/fs.ts @@ -0,0 +1,26 @@ +export async function isDirectory(path: string): Promise { + try { + const s = await Deno.stat(path) + return s.isDirectory + } catch { + return false + } +} + +export async function isFile(path: string): Promise { + try { + const s = await Deno.stat(path) + return s.isFile + } catch { + return false + } +} + +export async function isSymlink(path: string): Promise { + try { + const s = await Deno.lstat(path) + return s.isSymlink + } catch { + return false + } +} diff --git a/scripts/lib/git.ts b/scripts/lib/git.ts new file mode 100644 index 0000000..2947f4a --- /dev/null +++ b/scripts/lib/git.ts @@ -0,0 +1,41 @@ +import { dirname, resolve } from '@std/path' + +async function gitRevParse(worktreeRoot: string, arg: string): Promise { + const cmd = new Deno.Command('git', { + args: ['rev-parse', arg], + cwd: worktreeRoot, + stdout: 'piped', + stderr: 'piped', + }) + const { code, stdout, stderr } = await cmd.output() + if (code !== 0) { + const msg = new TextDecoder().decode(stderr).trim() + throw new Error(`git rev-parse ${arg} failed in ${worktreeRoot}: ${msg}`) + } + return new TextDecoder().decode(stdout).trim() +} + +export async function getGitDir(worktreeRoot: string): Promise { + return await gitRevParse(worktreeRoot, '--git-dir') +} + +export async function getGitCommonDir(worktreeRoot: string): Promise { + return await gitRevParse(worktreeRoot, '--git-common-dir') +} + +/** Returns primary repo path if worktreeRoot is a worktree, else null. */ +export async function resolvePrimaryRepo(worktreeRoot: string): Promise { + const gitDir = await getGitDir(worktreeRoot) + const gitCommonDir = await getGitCommonDir(worktreeRoot) + if (gitDir === gitCommonDir) return null + const absCommon = gitCommonDir.startsWith('/') + ? gitCommonDir + : resolve(worktreeRoot, gitCommonDir) + return dirname(absCommon) +} + +export async function runGit(args: string[], cwd: string): Promise<{ code: number }> { + const cmd = new Deno.Command('git', { args, cwd, stdout: 'null', stderr: 'null' }) + const { code } = await cmd.output() + return { code } +} diff --git a/scripts/lib/mod.ts b/scripts/lib/mod.ts new file mode 100644 index 0000000..1a71f11 --- /dev/null +++ b/scripts/lib/mod.ts @@ -0,0 +1,3 @@ +export * from './fs.ts' +export * from './git.ts' +export * from './paths.ts' diff --git a/scripts/lib/paths.ts b/scripts/lib/paths.ts new file mode 100644 index 0000000..eb67108 --- /dev/null +++ b/scripts/lib/paths.ts @@ -0,0 +1,14 @@ +import { relative } from '@std/path' + +/** + * Safe relative helper — returns null instead of throwing when paths are + * incompatible (different roots). Forward-looking vs bash `realpath --relative-to` + * which exits non-zero. + */ +export function tryRelative(from: string, to: string): string | null { + try { + return relative(from, to) + } catch { + return null + } +} diff --git a/scripts/post-switch.ts b/scripts/post-switch.ts new file mode 100755 index 0000000..a1750cb --- /dev/null +++ b/scripts/post-switch.ts @@ -0,0 +1,19 @@ +#!/usr/bin/env -S deno run --allow-run +async function postSwitch(worktreePath: string): Promise { + const cmd = new Deno.Command('git', { + args: ['-C', worktreePath, 'config', '--unset', 'extensions.relativeWorktrees'], + stdout: 'null', + stderr: 'null', + }) + await cmd.output() + console.log(`post-switch: done (${worktreePath})`) +} + +if (import.meta.main) { + const p = Deno.args[0] + if (!p) { + console.error('worktree_path required') + Deno.exit(1) + } + await postSwitch(p!) +} diff --git a/scripts/pre-merge.ts b/scripts/pre-merge.ts new file mode 100755 index 0000000..32445f2 --- /dev/null +++ b/scripts/pre-merge.ts @@ -0,0 +1,34 @@ +#!/usr/bin/env -S deno run --allow-read --allow-write --allow-run +import { join } from '@std/path' +import { isSymlink } from './lib/fs.ts' +import { runGit } from './lib/git.ts' + +const ISSUE_PATTERNS = [/^[0-9].*-.*\.md$/, /^T[0-9a-fA-F].*-.*\.md$/] + +async function preMerge(worktreePath: string): Promise { + for await (const entry of Deno.readDir(worktreePath)) { + if (!ISSUE_PATTERNS.some((re) => re.test(entry.name))) continue + const full = join(worktreePath, entry.name) + if (!(await isSymlink(full))) continue + try { + await Deno.remove(full) + console.log(`pre-merge: removed symlinked issue ${entry.name}`) + } catch (err) { + console.error(`pre-merge: failed to remove ${entry.name}: ${err}`) + } + } + const { code } = await runGit(['add', '-A'], worktreePath) + if (code !== 0) { + console.error(`pre-merge: git add -A failed (exit ${code})`) + Deno.exit(code) + } +} + +if (import.meta.main) { + const p = Deno.args[0] + if (!p) { + console.error('worktree_path required') + Deno.exit(1) + } + await preMerge(p) +} diff --git a/scripts/pre-start.ts b/scripts/pre-start.ts new file mode 100755 index 0000000..9190e89 --- /dev/null +++ b/scripts/pre-start.ts @@ -0,0 +1,136 @@ +#!/usr/bin/env -S deno run --allow-read --allow-write --allow-run --allow-env +import { dirname, relative } from '@std/path' +import { resolvePrimaryRepo } from './lib/git.ts' + +async function convertMainRepoGitdirToRelative( + worktreePath: string, + primaryPath: string, +): Promise { + const worktreesDir = `${primaryPath}/.git/worktrees` + try { + await Deno.stat(worktreesDir) + } catch { + return 0 + } + const expected = `${worktreePath}/.git` + let converted = 0 + for await (const entry of Deno.readDir(worktreesDir)) { + if (!entry.isDirectory) continue + const gitdirFile = `${worktreesDir}/${entry.name}/gitdir` + let target: string + try { + target = (await Deno.readTextFile(gitdirFile)).trim() + } catch { + continue + } + if (target !== expected || !target.startsWith('/')) continue + const baseDir = dirname(gitdirFile) + let rel: string + try { + rel = relative(baseDir, target) + } catch { + continue + } + await Deno.writeTextFile(gitdirFile, rel + '\n') + console.log(` gitdir: ${entry.name} -> ${rel}`) + converted++ + } + return converted +} + +async function convertWorktreeGitfileToRelative(worktreePath: string): Promise { + const gitFile = `${worktreePath}/.git` + let stat: Deno.FileInfo + try { + stat = await Deno.stat(gitFile) + } catch { + return false + } + if (!stat.isFile) return false + const line = (await Deno.readTextFile(gitFile)).split('\n')[0] ?? '' + if (!line.startsWith('gitdir:')) return false + const absPath = line.slice('gitdir:'.length).trim() + if (!absPath.startsWith('/')) return false + let rel: string + try { + rel = relative(worktreePath, absPath) + } catch { + return false + } + await Deno.writeTextFile(gitFile, `gitdir: ${rel}\n`) + console.log(` .git -> ${rel}`) + return true +} + +async function linkSharedDir( + name: string, + worktreePath: string, + primaryPath: string, +): Promise { + const src = `${primaryPath}/${name}` + try { + const s = await Deno.stat(src) + if (!s.isDirectory) { + console.log(`pre-start: no ${name} in primary, skipping symlink`) + return false + } + } catch { + console.log(`pre-start: no ${name} in primary, skipping symlink`) + return false + } + const target = `${worktreePath}/${name}` + try { + const t = await Deno.lstat(target) + if (!t.isSymlink) { + console.log(`pre-start: ${target} is a real directory, leaving it`) + return false + } + } catch { + // missing — create + } + try { + await Deno.remove(target) + } catch { + // ignore + } + const rel = relative(worktreePath, src) + await Deno.symlink(rel, target) + console.log(`pre-start: ${name} -> ${rel}`) + return true +} + +async function main(args: string[] = Deno.args): Promise { + const worktreePath = args[0] + if (!worktreePath) { + console.error('worktree_path required') + Deno.exit(1) + } + let primaryPath: string | null = args[1] ?? null + if (!primaryPath) { + try { + primaryPath = await resolvePrimaryRepo(worktreePath) + } catch { + primaryPath = null + } + } + if (primaryPath) { + await convertMainRepoGitdirToRelative(worktreePath, primaryPath) + await convertWorktreeGitfileToRelative(worktreePath) + } + const cmd = new Deno.Command('git', { + args: ['-C', worktreePath, 'config', '--unset', 'extensions.relativeWorktrees'], + stdout: 'null', + stderr: 'null', + }) + await cmd.output() + + if (!primaryPath) { + console.log('pre-start: running in primary repo (not a worktree) — done') + return + } + await linkSharedDir('.repos', worktreePath, primaryPath) + await linkSharedDir('.issues', worktreePath, primaryPath) + await linkSharedDir('wiki', worktreePath, primaryPath) +} + +if (import.meta.main) await main() diff --git a/scripts/worktree-to-relative.ts b/scripts/worktree-to-relative.ts new file mode 100755 index 0000000..28b0840 --- /dev/null +++ b/scripts/worktree-to-relative.ts @@ -0,0 +1,51 @@ +#!/usr/bin/env -S deno run --allow-read --allow-write --allow-run +import { dirname, relative } from '@std/path' +import { resolvePrimaryRepo } from './lib/git.ts' + +/** + * Convert all .git/worktrees//gitdir paths to relative. + * Uses Deno APIs directly, idempotent, reports count. + * CLI product output. + */ +async function worktreeToRelative(worktreePath = Deno.cwd()): Promise { + const primaryPath = await resolvePrimaryRepo(worktreePath) + if (!primaryPath) { + console.error(`error: not a git repo at ${worktreePath}`) + Deno.exit(1) + } + const worktreesDir = `${primaryPath}/.git/worktrees` + try { + await Deno.stat(worktreesDir) + } catch { + console.log('no worktrees') + return 0 + } + let count = 0 + for await (const entry of Deno.readDir(worktreesDir)) { + const gitdirFile = `${worktreesDir}/${entry.name}/gitdir` + let target: string + try { + target = (await Deno.readTextFile(gitdirFile)).trim() + } catch { + continue + } + if (!target.startsWith('/')) continue + const baseDir = dirname(gitdirFile) + let rel: string + try { + rel = relative(baseDir, target) + } catch { + continue + } + await Deno.writeTextFile(gitdirFile, rel + '\n') + console.log(` gitdir: ${entry.name} -> ${rel}`) + count++ + } + console.log(`converted ${count} worktree gitdir files`) + return count +} + +if (import.meta.main) { + const p = Deno.args[0] ?? Deno.cwd() + await worktreeToRelative(p) +}