diff --git a/AGENTS.md b/AGENTS.md index 82500af..4720c62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,8 +37,8 @@ Project conventions to keep updated as the repo evolves. worktree folder layout. - `gw switch`, `gw clone`, and `gw pr` are the commands that need shell-side directory changes. They communicate target directories through `GW_CWD_FILE`; - keep the shell wrappers in `src/core/shell.ts` synchronized if that command - set changes. + shell wrappers in `src/core/shell.ts` pass handoff files to every command so + newly added handoff commands do not require another static wrapper list. - Shell integration targets `bash`, `zsh`, `fish`, and `nu`. Missing shells may cause local smoke tests to skip, not fail. diff --git a/README.md b/README.md index 7961209..df4ccb2 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,7 @@ gw clone [--branch-prefix ] gw init [--branch-prefix ] gw shell-init [--shell bash|zsh|fish|nu] gw setup [--install] [--shell bash|zsh|fish|nu] +gw --version | gw -v gw help ``` @@ -358,7 +359,8 @@ Recommended branch/PR beta workflow: 2. Share either the generated tarball or a Git install command pinned to the tested commit SHA. 3. Have testers run `gw setup` or `eval "$(gw shell-init)"`, then verify - `gw clone`, `gw switch`, and `gw list` in a disposable worktree project. + `gw clone`, `gw switch`, `gw pr`, and `gw list` in a disposable worktree + project. ## Troubleshooting @@ -366,6 +368,10 @@ Recommended branch/PR beta workflow: directories: shell integration is not active in the current shell. Run `gw setup`, or run the session activation command for your shell. +If this starts after updating `gw`, the current shell may still have older +generated integration loaded. Run `gw setup --install --shell `, then +open a new shell or run the session activation command printed by `gw setup`. + `command gw switch ...` or `command gw pr ...` does not change directories: `command gw` bypasses the shell wrapper. Use `gw switch ...` or `gw pr ...`. diff --git a/package.json b/package.json index cb8855c..a103ed6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@amadeusdemarzi/git-gw", - "version": "1.0.0", + "version": "1.0.1", "description": "Opinionated CLI for branch-like Git worktree workflows", "keywords": [ "cli", diff --git a/scripts/pack-smoke.ts b/scripts/pack-smoke.ts index 0eb2b4f..66029a4 100644 --- a/scripts/pack-smoke.ts +++ b/scripts/pack-smoke.ts @@ -122,6 +122,22 @@ async function createRemoteFixture(rootDir: string): Promise { ); await runGit(['push', '-u', 'origin', 'feature/test'], seedPath); + await runGit(['checkout', '-b', 'pr_123'], seedPath); + await runGit( + [ + '-c', + 'user.name=gw-test', + '-c', + 'user.email=gw-test@example.com', + 'commit', + '--allow-empty', + '-m', + 'pr', + ], + seedPath + ); + await runGit(['push', '-u', 'origin', 'pr_123'], seedPath); + return originPath; } @@ -143,13 +159,22 @@ async function verifyInstalledCli(installDir: string): Promise { env, }); + const versionResult = await execa('gw', ['--version'], { + cwd: installDir, + env, + }); + + if (!versionResult.stdout.trim()) { + throw new Error('gw --version did not print a version'); + } + await execa( 'bash', [ '--noprofile', '--norc', '-c', - 'set -e; source <(gw shell-init); cd "$1"; gw clone demo "$2" >/dev/null 2>/dev/null; test "$(pwd -P)" = "$1/demo/main"; gw switch feature/test >/dev/null 2>/dev/null; test "$(pwd -P)" = "$1/demo/feature~test"; gw list >/dev/null', + 'set -e; source <(gw shell-init); cd "$1"; gw clone demo "$2" >/dev/null 2>/dev/null; test "$(pwd -P)" = "$1/demo/main"; gw switch feature/test >/dev/null 2>/dev/null; test "$(pwd -P)" = "$1/demo/feature~test"; cd "$1/demo/main"; gw switch pr_123 >/dev/null 2>/dev/null; test "$(pwd -P)" = "$1/demo/pr_123"; cd "$1/demo/main"; gw pr 123 >/dev/null 2>/dev/null; test "$(pwd -P)" = "$1/demo/pr_123"; gw list >/dev/null', '_', resolvedWorkDir, originPath, diff --git a/src/cli.ts b/src/cli.ts index 97d1d40..0b28554 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,12 +9,15 @@ import { registerRemoveCommand } from '@/commands/remove'; import { registerSetupCommand } from '@/commands/setup'; import { registerShellInitCommand } from '@/commands/shell-init'; import { registerSwitchCommand } from '@/commands/switch'; +import { readPackageVersion } from '@/core/package-version'; const program = new Command(); +const packageVersion = await readPackageVersion(); program .name('gw') .description('Manage git worktree projects') + .version(packageVersion, '-v, --version') .showHelpAfterError(); registerListCommand(program); diff --git a/src/core/package-version.ts b/src/core/package-version.ts new file mode 100644 index 0000000..613033e --- /dev/null +++ b/src/core/package-version.ts @@ -0,0 +1,51 @@ +import { readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +interface PackageJson { + version?: unknown; +} + +function isNotFoundError(error: unknown): boolean { + return Boolean( + error && + typeof error === 'object' && + 'code' in error && + error.code === 'ENOENT' + ); +} + +export async function readPackageVersion( + startUrl = import.meta.url +): Promise { + let currentDir = dirname(fileURLToPath(startUrl)); + + while (true) { + const packageJsonPath = join(currentDir, 'package.json'); + + try { + const packageJson = JSON.parse( + await readFile(packageJsonPath, 'utf8') + ) as PackageJson; + + if (typeof packageJson.version !== 'string') { + throw new Error( + `package.json version must be a string: ${packageJsonPath}` + ); + } + + return packageJson.version; + } catch (error) { + if (!isNotFoundError(error)) { + throw error; + } + } + + const parentDir = dirname(currentDir); + if (parentDir === currentDir) { + throw new Error('could not find package.json'); + } + + currentDir = parentDir; + } +} diff --git a/src/core/shell.ts b/src/core/shell.ts index 3b6f4f1..2d914b4 100644 --- a/src/core/shell.ts +++ b/src/core/shell.ts @@ -273,189 +273,122 @@ export function getSessionActivationCommand(shell: ShellName): string { } function renderBashLikeShellInit(): string { - return `__gw_needs_cd() { - case "$1" in - switch|clone|pr) return 0 ;; - *) return 1 ;; - esac -} - -__gw_needs_source() { - case "$1" in - setup) return 0 ;; - *) return 1 ;; - esac -} + return `gw() { + if [[ $# -eq 0 ]]; then + command gw + return + fi -gw() { - if [[ $# -gt 0 ]] && __gw_needs_cd "$1"; then - local _gw_tmpfile - _gw_tmpfile="$(mktemp "\${TMPDIR:-/tmp}/gw.XXXXXX")" || return 1 + local _gw_cwd_file _gw_source_file _gw_status + _gw_cwd_file="$(mktemp "\${TMPDIR:-/tmp}/gw-cwd.XXXXXX")" || return 1 + _gw_source_file="$(mktemp "\${TMPDIR:-/tmp}/gw-source.XXXXXX")" || { + rm -f "$_gw_cwd_file" + return 1 + } - GW_CWD_FILE="$_gw_tmpfile" command gw "$@" - local _gw_status=$? + GW_CWD_FILE="$_gw_cwd_file" GW_SOURCE_FILE="$_gw_source_file" command gw "$@" + _gw_status=$? - if [[ $_gw_status -eq 0 && -s "$_gw_tmpfile" ]]; then - local _gw_target - IFS= read -r _gw_target < "$_gw_tmpfile" - if [[ -n "$_gw_target" ]]; then - builtin cd "$_gw_target" || _gw_status=$? - fi + if [[ $_gw_status -eq 0 && -s "$_gw_source_file" ]]; then + local _gw_source + IFS= read -r _gw_source < "$_gw_source_file" + if [[ -n "$_gw_source" && -f "$_gw_source" ]]; then + source "$_gw_source" || _gw_status=$? fi - - rm -f "$_gw_tmpfile" - return $_gw_status fi - if [[ $# -gt 0 ]] && __gw_needs_source "$1"; then - local _gw_tmpfile - _gw_tmpfile="$(mktemp "\${TMPDIR:-/tmp}/gw.XXXXXX")" || return 1 - - GW_SOURCE_FILE="$_gw_tmpfile" command gw "$@" - local _gw_status=$? - - if [[ $_gw_status -eq 0 && -s "$_gw_tmpfile" ]]; then - local _gw_source - IFS= read -r _gw_source < "$_gw_tmpfile" - if [[ -n "$_gw_source" && -f "$_gw_source" ]]; then - source "$_gw_source" || _gw_status=$? - fi + if [[ $_gw_status -eq 0 && -s "$_gw_cwd_file" ]]; then + local _gw_target + IFS= read -r _gw_target < "$_gw_cwd_file" + if [[ -n "$_gw_target" ]]; then + builtin cd "$_gw_target" || _gw_status=$? fi - - rm -f "$_gw_tmpfile" - return $_gw_status fi - command gw "$@" + rm -f "$_gw_cwd_file" "$_gw_source_file" + return $_gw_status } `; } function renderFishShellInit(): string { - return `function __gw_needs_cd - switch $argv[1] - case switch clone pr - return 0 - case '*' - return 1 + return `function gw + if test (count $argv) -eq 0 + command gw + return $status end -end -function __gw_needs_source - switch $argv[1] - case setup - return 0 - case '*' - return 1 + set -l tmpdir /tmp + if set -q TMPDIR + set tmpdir $TMPDIR end -end -function gw - if test (count $argv) -gt 0; and __gw_needs_cd $argv[1] - set -l tmpdir /tmp - if set -q TMPDIR - set tmpdir $TMPDIR - end + set -l cwd_file (mktemp "$tmpdir/gw-cwd.XXXXXX") + or return 1 - set -l tmpfile (mktemp "$tmpdir/gw.XXXXXX") - or return 1 + set -l source_file (mktemp "$tmpdir/gw-source.XXXXXX") + or begin + command rm -f -- "$cwd_file" + return 1 + end - env GW_CWD_FILE="$tmpfile" command gw $argv - set -l _gw_status $status + env GW_CWD_FILE="$cwd_file" GW_SOURCE_FILE="$source_file" command gw $argv + set -l _gw_status $status - if test $_gw_status -eq 0; and test -s "$tmpfile" - set -l target (string trim -- (command cat -- "$tmpfile")) - if test -n "$target" - cd "$target" - or set _gw_status $status - end + if test $_gw_status -eq 0; and test -s "$source_file" + set -l source_path (string trim -- (command cat -- "$source_file")) + if test -n "$source_path"; and test -f "$source_path" + source "$source_path" + or set _gw_status $status end - - command rm -f -- "$tmpfile" - return $_gw_status end - if test (count $argv) -gt 0; and __gw_needs_source $argv[1] - set -l tmpdir /tmp - if set -q TMPDIR - set tmpdir $TMPDIR - end - - set -l tmpfile (mktemp "$tmpdir/gw.XXXXXX") - or return 1 - - env GW_SOURCE_FILE="$tmpfile" command gw $argv - set -l _gw_status $status - - if test $_gw_status -eq 0; and test -s "$tmpfile" - set -l source_path (string trim -- (command cat -- "$tmpfile")) - if test -n "$source_path"; and test -f "$source_path" - source "$source_path" - or set _gw_status $status - end + if test $_gw_status -eq 0; and test -s "$cwd_file" + set -l target (string trim -- (command cat -- "$cwd_file")) + if test -n "$target" + cd "$target" + or set _gw_status $status end - - command rm -f -- "$tmpfile" - return $_gw_status end - command gw $argv + command rm -f -- "$cwd_file" "$source_file" + return $_gw_status end `; } function renderNuShellInit(): string { - return `def __gw_needs_cd [cmd: string] { - $cmd in ['switch', 'clone', 'pr'] -} - -def __gw_needs_source [cmd: string] { - $cmd == 'setup' -} - -def --env gw [...args] { + return `def --env gw [...args] { if (($args | length) == 0) { ^gw return } - let cmd = ($args | first) - - if not (__gw_needs_cd $cmd) and not (__gw_needs_source $cmd) { - ^gw ...$args - return - } - let tmpdir = ($env.TMPDIR? | default '/tmp') - let tmpfile = (^mktemp $"($tmpdir)/gw.XXXXXX" | str trim) - - let handoff_env = if (__gw_needs_cd $cmd) { - { GW_CWD_FILE: $tmpfile } - } else { - { GW_SOURCE_FILE: $tmpfile } - } + let cwd_file = (^mktemp $"($tmpdir)/gw-cwd.XXXXXX" | str trim) + let source_file = (^mktemp $"($tmpdir)/gw-source.XXXXXX" | str trim) - with-env $handoff_env { + with-env { GW_CWD_FILE: $cwd_file, GW_SOURCE_FILE: $source_file } { ^gw ...$args } let status = ($env.LAST_EXIT_CODE? | default 0) - if ($status == 0) and (__gw_needs_cd $cmd) and ($tmpfile | path exists) { - let target = (open --raw $tmpfile | str trim) - if $target != '' { - cd $target + if ($status == 0) and ($source_file | path exists) { + let source_path = (open --raw $source_file | str trim) + if ($source_path != '') and ($source_path | path exists) { + source $source_path } } - if ($status == 0) and (__gw_needs_source $cmd) and ($tmpfile | path exists) { - let source_file = (open --raw $tmpfile | str trim) - if ($source_file != '') and ($source_file | path exists) { - source $source_file + if ($status == 0) and ($cwd_file | path exists) { + let target = (open --raw $cwd_file | str trim) + if $target != '' { + cd $target } } - ^rm -f $tmpfile + ^rm -f $cwd_file $source_file load-env { LAST_EXIT_CODE: $status } } `; diff --git a/test/cli/commands.integration.test.ts b/test/cli/commands.integration.test.ts index 4f8f96e..a87cd9a 100644 --- a/test/cli/commands.integration.test.ts +++ b/test/cli/commands.integration.test.ts @@ -5,18 +5,27 @@ import { createWorkDir, makeTempDir, pathExists, + REPO_ROOT, runCli, runCliWithCwdCapture, runGit, } from '@test/helpers'; import { execa } from 'execa'; -import { chmod, mkdir, symlink, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, readFile, symlink, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { hasGwConfig } from '@/core/config'; import { branchExists } from '@/core/git'; +async function readExpectedPackageVersion(): Promise { + const packageJson = JSON.parse( + await readFile(join(REPO_ROOT, 'package.json'), 'utf8') + ) as { version: string }; + + return packageJson.version; +} + async function createFakeGh( rootDir: string, prNumber: string, @@ -81,6 +90,19 @@ async function createPathWithoutGh(): Promise { } describe('CLI integration', () => { + it('prints the package version', async () => { + const workDir = await makeTempDir('gw-version-'); + const packageVersion = await readExpectedPackageVersion(); + + const longResult = await runCli(['--version'], { cwd: workDir }); + expect(longResult.exitCode).toBe(0); + expect(longResult.stdout).toBe(packageVersion); + + const shortResult = await runCli(['-v'], { cwd: workDir }); + expect(shortResult.exitCode).toBe(0); + expect(shortResult.stdout).toBe(packageVersion); + }); + it('covers clone, list, switch, and remove against a real remote', async () => { const fixture = await createRemoteFixture(['feature/test'], 'main'); const workDir = await createWorkDir(fixture.rootDir); diff --git a/test/cli/shell.integration.test.ts b/test/cli/shell.integration.test.ts index b3985c0..6e72d17 100644 --- a/test/cli/shell.integration.test.ts +++ b/test/cli/shell.integration.test.ts @@ -20,7 +20,7 @@ beforeAll(async () => { }); async function createShellFixture() { - const fixture = await createRemoteFixture(['feature/test'], 'main'); + const fixture = await createRemoteFixture(['feature/test', 'pr_123'], 'main'); const launcherDir = await makeTempDir('gw-shell-launcher-'); const workDir = join(fixture.rootDir, 'work'); const homeDir = await makeTempDir('gw-shell-home-'); @@ -79,7 +79,7 @@ describe('shell wrapper integration', () => { '--noprofile', '--norc', '-c', - 'set -e; source <(gw shell-init); cd "$1"; gw clone demo "$2" >/dev/null 2>/dev/null; test "$(pwd -P)" = "$1/demo/main"; gw switch feature/test >/dev/null 2>/dev/null; test "$(pwd -P)" = "$1/demo/feature~test"; gw list >/dev/null; printf OK', + 'set -e; source <(gw shell-init); cd "$1"; gw clone demo "$2" >/dev/null 2>/dev/null; test "$(pwd -P)" = "$1/demo/main"; gw switch feature/test >/dev/null 2>/dev/null; test "$(pwd -P)" = "$1/demo/feature~test"; cd "$1/demo/main"; gw switch pr_123 >/dev/null 2>/dev/null; test "$(pwd -P)" = "$1/demo/pr_123"; cd "$1/demo/main"; gw pr 123 >/dev/null 2>/dev/null; test "$(pwd -P)" = "$1/demo/pr_123"; gw list >/dev/null; printf OK', '_', fixture.workDir, fixture.originPath, @@ -125,7 +125,7 @@ describe('shell wrapper integration', () => { [ '-f', '-c', - 'set -e; source <(gw shell-init); cd "$1"; gw clone demo "$2" >/dev/null 2>/dev/null; [[ "$(pwd -P)" == "$1/demo/main" ]]; gw switch feature/test >/dev/null 2>/dev/null; [[ "$(pwd -P)" == "$1/demo/feature~test" ]]; gw list >/dev/null; printf OK', + 'set -e; source <(gw shell-init); cd "$1"; gw clone demo "$2" >/dev/null 2>/dev/null; [[ "$(pwd -P)" == "$1/demo/main" ]]; gw switch feature/test >/dev/null 2>/dev/null; [[ "$(pwd -P)" == "$1/demo/feature~test" ]]; cd "$1/demo/main"; gw switch pr_123 >/dev/null 2>/dev/null; [[ "$(pwd -P)" == "$1/demo/pr_123" ]]; cd "$1/demo/main"; gw pr 123 >/dev/null 2>/dev/null; [[ "$(pwd -P)" == "$1/demo/pr_123" ]]; gw list >/dev/null; printf OK', '_', fixture.workDir, fixture.originPath, @@ -200,7 +200,7 @@ describe('shell wrapper integration', () => { [ '--no-config', '-c', - 'gw shell-init | source; cd $argv[1]; gw clone demo $argv[2] >/dev/null 2>/dev/null; test (realpath .) = "$argv[1]/demo/main"; or exit 1; gw switch feature/test >/dev/null 2>/dev/null; test (realpath .) = "$argv[1]/demo/feature~test"; or exit 1; gw list >/dev/null; printf OK', + 'gw shell-init | source; cd $argv[1]; gw clone demo $argv[2] >/dev/null 2>/dev/null; test (realpath .) = "$argv[1]/demo/main"; or exit 1; gw switch feature/test >/dev/null 2>/dev/null; test (realpath .) = "$argv[1]/demo/feature~test"; or exit 1; cd $argv[1]/demo/main; gw switch pr_123 >/dev/null 2>/dev/null; test (realpath .) = "$argv[1]/demo/pr_123"; or exit 1; cd $argv[1]/demo/main; gw pr 123 >/dev/null 2>/dev/null; test (realpath .) = "$argv[1]/demo/pr_123"; or exit 1; gw list >/dev/null; printf OK', fixture.workDir, fixture.originPath, ], @@ -222,7 +222,7 @@ describe('shell wrapper integration', () => { 'nu', [ '-c', - 'gw shell-init | save --force $env.GW_INIT_PATH; source $env.GW_INIT_PATH; cd $env.GW_WORKDIR; gw clone demo $env.GW_ORIGIN | ignore; if ((pwd | get path) != $"($env.GW_WORKDIR)/demo/main") { exit 1 }; gw switch feature/test | ignore; if ((pwd | get path) != $"($env.GW_WORKDIR)/demo/feature~test") { exit 1 }; gw list | ignore', + 'gw shell-init | save --force $env.GW_INIT_PATH; source $env.GW_INIT_PATH; cd $env.GW_WORKDIR; gw clone demo $env.GW_ORIGIN | ignore; if ((pwd | get path) != $"($env.GW_WORKDIR)/demo/main") { exit 1 }; gw switch feature/test | ignore; if ((pwd | get path) != $"($env.GW_WORKDIR)/demo/feature~test") { exit 1 }; cd $"($env.GW_WORKDIR)/demo/main"; gw switch pr_123 | ignore; if ((pwd | get path) != $"($env.GW_WORKDIR)/demo/pr_123") { exit 1 }; cd $"($env.GW_WORKDIR)/demo/main"; gw pr 123 | ignore; if ((pwd | get path) != $"($env.GW_WORKDIR)/demo/pr_123") { exit 1 }; gw list | ignore', ], { env: { diff --git a/test/core/shell.test.ts b/test/core/shell.test.ts index aadff3d..c306770 100644 --- a/test/core/shell.test.ts +++ b/test/core/shell.test.ts @@ -3,11 +3,18 @@ import { describe, expect, it } from 'vitest'; import { DIRECTORY_CHANGE_COMMANDS, renderShellInit } from '@/core/shell'; describe('shell integration helpers', () => { - it('treats gw pr as a directory-changing command', () => { + it('tracks gw pr as a directory-changing command', () => { expect(DIRECTORY_CHANGE_COMMANDS).toContain('pr'); - expect(renderShellInit('bash')).toContain('switch|clone|pr'); - expect(renderShellInit('zsh')).toContain('switch|clone|pr'); - expect(renderShellInit('fish')).toContain('case switch clone pr'); - expect(renderShellInit('nu')).toContain("['switch', 'clone', 'pr']"); + }); + + it('passes handoff files through every generated wrapper', () => { + expect(renderShellInit('bash')).toContain('GW_CWD_FILE'); + expect(renderShellInit('bash')).toContain('GW_SOURCE_FILE'); + expect(renderShellInit('zsh')).toContain('GW_CWD_FILE'); + expect(renderShellInit('zsh')).toContain('GW_SOURCE_FILE'); + expect(renderShellInit('fish')).toContain('GW_CWD_FILE'); + expect(renderShellInit('fish')).toContain('GW_SOURCE_FILE'); + expect(renderShellInit('nu')).toContain('GW_CWD_FILE'); + expect(renderShellInit('nu')).toContain('GW_SOURCE_FILE'); }); });