Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ wheels/
# IDE
.idea/
/*.iml
.aiassistant/

# Testing
playground/
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ Or download the PEX file from the [releases page](https://github.com/bjester/gh-
- Lifecycle hooks with script checksum validation
- Global and project configuration
- Create worktrees from GitHub PRs
- Worktree templates
- Terminal autocomplete (coming soon)
- Worktree templates (coming soon)
- Project hook initialization (coming soon)
- Feature detection and hook bifurcation (planned)

Expand All @@ -35,6 +35,7 @@ gh-worktree/
.gh/
worktree/
hooks/
templates/
config.json
```
When you create new worktrees, they'll be added as directories to the root directory:
Expand All @@ -46,6 +47,7 @@ gh-worktree/
# ... etc ...
```

### Hooks
You may add hooks to `.gh/worktree/hooks` so that you may trigger custom functionality during the lifecycle of your worktrees. The hooks are configurable in the project, but also globally. The first global `.gh/worktree/hooks` found upwards in the directory tree, outside the project directory (i.e. above `gh-worktree/`), will be executed. The following hooks are available:
- `pre_init`: before initializing a repository for use with worktrees (global only)
- `post_init`: after initializing a repository for use with worktrees (global only)
Expand All @@ -71,6 +73,13 @@ uv sync --group dev
popd
```

### Templates
You may add files to `.gh/worktree/templates` which will get copied into new worktrees. The files are copied before the post-hooks are executed. It's a good idea to add these files to the project's `.gitignore`. The files can optionally contain variables that will be replaced during the copy process. The variables should be defined like environment variables: `${ENVVAR_NAME}`. To allowlist environment variables for use in templates, add their names to the `allowed_envvars` list in your global config (probably `~/.gh/worktree/config.json`). The following are variables that are provided by default:
- `REPO_NAME`: the name of the git repository
- `REPO_DIR`: the absolute path of the repo / project directory
- `WORKTREE_NAME`: the name of the new worktree
- `WORKTREE_DIR`: the absolute path of the worktree directory

## Commands
You may use `--help` for any command for usage information. To see a list of commands, run `gh-worktree` without any arguments.

Expand Down
1 change: 1 addition & 0 deletions src/gh_worktree/commands/checkout.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ def __call__(self, branch_or_pr: str, remote: Optional[str] = None): # noqa: C9
f"{inpt.remote}/{inpt.worktree_name}",
)
self._runtime.git.open_worktree(inpt.worktree_name)
self._runtime.templates.copy(inpt.worktree_name)
self._runtime.hooks.fire(
Hook.post_checkout,
inpt.worktree_name,
Expand Down
1 change: 1 addition & 0 deletions src/gh_worktree/commands/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ def __call__(self, worktree_name: str, *base_ref: Optional[str]):
self._runtime.git.add_worktree(
worktree_name, f"{git_remote_name}/{base_ref}"
)
self._runtime.templates.copy(worktree_name)
self._runtime.hooks.fire(
Hook.post_create, worktree_name, f"{git_remote_name}/{base_ref}"
)
5 changes: 5 additions & 0 deletions src/gh_worktree/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
from typing import Dict
from typing import List


class Config(object):
Expand Down Expand Up @@ -31,6 +32,10 @@ class GlobalConfig(Config):
def allowed_hooks(self) -> Dict[str, str]:
return self._data.get("allowed_hooks", {})

@property
def allowed_envvars(self) -> List[str]:
return self._data.get("allowed_envvars", [])

def allow_hook(self, path: str, checksum: str):
hooks = self.allowed_hooks.copy()
hooks[path] = checksum
Expand Down
14 changes: 5 additions & 9 deletions src/gh_worktree/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from enum import Enum

from gh_worktree.context import Context
from gh_worktree.operator import ConfigOperator
from gh_worktree.utils import stream_exec


Expand All @@ -27,20 +28,15 @@ class HookExists(Exception):
pass


class Hooks(object):
class Hooks(ConfigOperator):
def __init__(self, context: Context):
self.context = context
super().__init__(context)
self.dir_name = "hooks"

def fire(self, hook: Hook, *args, skip_project: bool = False) -> bool:
fired = False
configs = [self.context.global_config_dir]
if not skip_project:
configs.append(self.context.config_dir)

for config_dir in configs:
hooks_dir = os.path.join(config_dir, "hooks")
if not os.path.exists(hooks_dir):
continue
for hooks_dir in self.iter_config_dirs(skip_project=skip_project):
hook_file = os.path.join(hooks_dir, hook.name)
if not os.path.exists(hook_file):
continue
Expand Down
23 changes: 23 additions & 0 deletions src/gh_worktree/operator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import os
from typing import Iterator

from gh_worktree.context import Context


class ConfigOperator(object):
dir_name: str

def __init__(self, context: Context):
self.context = context

def iter_config_dirs(self, skip_project: bool = False) -> Iterator[str]:
configs = [self.context.global_config_dir]
if not skip_project:
configs.append(self.context.config_dir)

for config_dir in configs:
op_dir = os.path.join(config_dir, self.dir_name)
if not os.path.exists(op_dir):
continue

yield op_dir
4 changes: 3 additions & 1 deletion src/gh_worktree/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,18 @@
from gh_worktree.git import GitCLI
from gh_worktree.git import GitRemote
from gh_worktree.hooks import Hooks
from gh_worktree.templates import Templates


class Runtime(object):
__slots__ = ("context", "hooks", "git", "gh")
__slots__ = ("context", "hooks", "git", "gh", "templates")

def __init__(self):
self.context = Context()
self.hooks = Hooks(self.context)
self.git = GitCLI(self.context)
self.gh = GithubCLI(self.context)
self.templates = Templates(self.context)

def get_default_remote(self) -> Optional[GitRemote]:
return self.get_remote(owner_name=self.context.get_config().owner)
Expand Down
51 changes: 51 additions & 0 deletions src/gh_worktree/templates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import os
from pathlib import Path
from string import Template

from gh_worktree.context import Context
from gh_worktree.operator import ConfigOperator


class Templates(ConfigOperator):
"""
Copies files from /templates directory into a worktree, replacing environment variables in the
process.
"""

def __init__(self, context: Context):
super().__init__(context)
self.dir_name = "templates"
self.replacement_map = {}

global_config = context.get_global_config()
for envvar_name in global_config.allowed_envvars:
self.replacement_map[envvar_name] = os.environ.get(envvar_name, "")

def copy(self, worktree_name: str):
config = self.context.get_config()
worktree_dir = Path(os.path.join(self.context.project_dir, worktree_name))

self.replacement_map["REPO_NAME"] = config.name
self.replacement_map["REPO_DIR"] = self.context.project_dir
self.replacement_map["WORKTREE_NAME"] = worktree_name
self.replacement_map["WORKTREE_DIR"] = str(worktree_dir)

for templates_dir in self.iter_config_dirs():
for path in Path(templates_dir).rglob("*"):
relative_path = path.relative_to(templates_dir)
self._copy(worktree_dir, path, relative_path)

def _copy(self, worktree_dir: Path, absolute_path: Path, relative_path: Path):
dest_path = worktree_dir / relative_path

if absolute_path.is_dir():
dest_path.mkdir(parents=True, exist_ok=True)
return

print(f"Copying template {relative_path}")
with absolute_path.open("r", encoding="utf-8") as src:
with dest_path.open("w", encoding="utf-8") as dest:
content = src.read()
dest.write(Template(content).safe_substitute(self.replacement_map))

dest_path.chmod(absolute_path.stat().st_mode)
26 changes: 16 additions & 10 deletions tests/commands/test_checkout.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,71 +37,77 @@ def setUp(self):
self.hooks = SimpleNamespace(fire=Mock())
self.git = SimpleNamespace(open_worktree=Mock(), fetch=Mock())
self.gh = SimpleNamespace(pr_status=Mock())
self.templates = SimpleNamespace(copy=Mock())
self.runtime = SimpleNamespace(
context=self.context,
hooks=self.hooks,
git=self.git,
gh=self.gh,
templates=self.templates,
get_remote=Mock(return_value=GitRemote("origin", "uri", "fetch")),
get_default_remote=Mock(return_value=GitRemote("origin", "uri", "fetch")),
)
self.command = CheckoutCommand(self.runtime)

def test_checkout_command_uses_branch_name(self):
def test_call__uses_branch_name(self):
self.command("feature")

assert self.context.assert_called is True
self.assertTrue(self.context.assert_called)

self.git.fetch.assert_called_once_with("origin", "feature:feature")
self.runtime.get_remote.assert_called_once_with(owner_name="octo")
self.git.open_worktree.assert_called_once_with("feature")
self.templates.copy.assert_called_once_with("feature")
self.hooks.fire.assert_any_call(Hook.pre_checkout, "feature", ANY)
self.hooks.fire.assert_any_call(Hook.post_checkout, "feature", ANY)

def test_checkout_command_uses_remote_and_branch_name(self):
def test_call__uses_remote_and_branch_name(self):
self.command("feature", remote="aremote")

assert self.context.assert_called is True
self.assertTrue(self.context.assert_called)

self.git.fetch.assert_called_once_with("aremote", "feature:feature")
self.git.open_worktree.assert_called_once_with("feature")
self.templates.copy.assert_called_once_with("feature")
self.hooks.fire.assert_any_call(Hook.pre_checkout, "feature", ANY)
self.hooks.fire.assert_any_call(Hook.post_checkout, "feature", ANY)

def test_checkout_command_uses_pr_number(self):
def test_call__uses_pr_number(self):
self.gh.pr_status.return_value = {
"headRefName": "feature",
}

self.command("1234")

assert self.context.assert_called is True
self.assertTrue(self.context.assert_called)
self.gh.pr_status.assert_called_once_with("1234", owner_repo="octo/repo")
self.runtime.get_remote.assert_called_once_with(owner_name="octo")
self.git.open_worktree.assert_called_once_with("feature")
self.templates.copy.assert_called_once_with("feature")
self.hooks.fire.assert_any_call(Hook.pre_checkout, "feature", ANY)
self.hooks.fire.assert_any_call(Hook.post_checkout, "feature", ANY)

def test_checkout_command_uses_pr_url(self):
def test_call__uses_pr_url(self):
self.gh.pr_status.return_value = {
"headRefName": "feature",
}

self.command("https://github.com/octo/repo/pull/1234")

assert self.context.assert_called is True
self.assertTrue(self.context.assert_called)
self.gh.pr_status.assert_called_once_with("1234", owner_repo="octo/repo")
self.runtime.get_remote.assert_called_once_with(owner_name="octo")
self.git.open_worktree.assert_called_once_with("feature")
self.templates.copy.assert_called_once_with("feature")
self.hooks.fire.assert_any_call(Hook.pre_checkout, "feature", ANY)
self.hooks.fire.assert_any_call(Hook.post_checkout, "feature", ANY)

def test_checkout_command_raises_on_missing_remote(self):
def test_call__raises_on_missing_remote(self):
self.runtime.get_remote.return_value = None

with self.assertRaises(AssertionError, msg="Couldn't find matching remote"):
self.command("feature")

def test_checkout_command_rejects_invalid_pull_url(self):
def test_call__rejects_invalid_pull_url(self):
with self.assertRaises(ValueError, msg="Couldn't parse input. Must be"):
self.command("https://github.com/octo/repo/issues/12")
84 changes: 49 additions & 35 deletions tests/commands/test_create.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from contextlib import contextmanager
from types import SimpleNamespace
from unittest import TestCase
from unittest.mock import Mock

from gh_worktree.commands.create import CreateCommand
Expand All @@ -24,44 +25,57 @@ def use(self, cwd):
yield


def test_create_command_uses_default_branch_and_remote():
context = StubContext("/repo", SimpleNamespace(default_branch="main"))
hooks = SimpleNamespace(fire=Mock())
git = SimpleNamespace(fetch=Mock(), add_worktree=Mock())
runtime = SimpleNamespace(
context=context,
hooks=hooks,
git=git,
get_remote=Mock(return_value=GitRemote("origin", "uri", "fetch")),
)
class CreateCommandTestCase(TestCase):
def setUp(self):
self.config = SimpleNamespace(default_branch="main")
self.context = StubContext("/repo", self.config)
self.hooks = SimpleNamespace(fire=Mock())
self.git = SimpleNamespace(fetch=Mock(), add_worktree=Mock())
self.templates = SimpleNamespace(copy=Mock())
self.runtime = SimpleNamespace(
context=self.context,
hooks=self.hooks,
git=self.git,
templates=self.templates,
get_remote=Mock(return_value=GitRemote("origin", "uri", "fetch")),
)
self.command = CreateCommand(self.runtime)

command = CreateCommand(runtime)
command("feature")
def test_call__uses_default_branch_and_remote(self):
self.command("feature")

assert context.assert_called is True
runtime.get_remote.assert_called_once_with()
git.fetch.assert_called_once_with("origin")
git.add_worktree.assert_called_once_with("feature", "origin/main")
hooks.fire.assert_any_call(Hook.pre_create, "feature", "origin/main")
hooks.fire.assert_any_call(Hook.post_create, "feature", "origin/main")
self.assertTrue(self.context.assert_called)
self.runtime.get_remote.assert_called_once_with()
self.git.fetch.assert_called_once_with("origin")
self.git.add_worktree.assert_called_once_with("feature", "origin/main")
self.templates.copy.assert_called_once_with("feature")
self.hooks.fire.assert_any_call(Hook.pre_create, "feature", "origin/main")
self.hooks.fire.assert_any_call(Hook.post_create, "feature", "origin/main")

def test_call__respects_explicit_remote_and_ref(self):
self.command("feature", "upstream/dev")

def test_create_command_respects_explicit_remote_and_ref():
context = StubContext("/repo", SimpleNamespace(default_branch="main"))
hooks = SimpleNamespace(fire=Mock())
git = SimpleNamespace(fetch=Mock(), add_worktree=Mock())
runtime = SimpleNamespace(
context=context,
hooks=hooks,
git=git,
get_remote=Mock(),
)
self.assertTrue(self.context.assert_called)
self.runtime.get_remote.assert_not_called()
self.git.fetch.assert_called_once_with("upstream")
self.git.add_worktree.assert_called_once_with("feature", "upstream/dev")
self.templates.copy.assert_called_once_with("feature")
self.hooks.fire.assert_any_call(Hook.pre_create, "feature", "upstream/dev")
self.hooks.fire.assert_any_call(Hook.post_create, "feature", "upstream/dev")

command = CreateCommand(runtime)
command("feature", "upstream/dev")
def test_call__handles_ref_with_slashes(self):
self.command("feature", "upstream/some/nested/branch")

runtime.get_remote.assert_not_called()
git.fetch.assert_called_once_with("upstream")
git.add_worktree.assert_called_once_with("feature", "upstream/dev")
hooks.fire.assert_any_call(Hook.pre_create, "feature", "upstream/dev")
hooks.fire.assert_any_call(Hook.post_create, "feature", "upstream/dev")
self.assertTrue(self.context.assert_called)
self.runtime.get_remote.assert_not_called()
self.git.fetch.assert_called_once_with("upstream")
self.git.add_worktree.assert_called_once_with(
"feature", "upstream/some/nested/branch"
)
self.templates.copy.assert_called_once_with("feature")
self.hooks.fire.assert_any_call(
Hook.pre_create, "feature", "upstream/some/nested/branch"
)
self.hooks.fire.assert_any_call(
Hook.post_create, "feature", "upstream/some/nested/branch"
)
Loading