treefort 0.5.0b0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
treefort/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("treefort")
5
+ except PackageNotFoundError:
6
+ # Package is not installed
7
+ __version__ = "unknown"
treefort/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from treefort.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
treefort/cli.py ADDED
@@ -0,0 +1,67 @@
1
+ import sys
2
+
3
+ import fire
4
+
5
+ from treefort.main import WorktreeCommands
6
+
7
+
8
+ def replace_alias(cli: WorktreeCommands):
9
+ """
10
+ Replaces an alias with its corresponding full subcommand name in the system
11
+ arguments if the provided subcommand is an alias.
12
+
13
+ This function inspects the command-line arguments and, if an alias is found
14
+ as the first subcommand, replaces it with the full subcommand name as defined
15
+ in the alias map of the provided `WorktreeCommands` instance.
16
+
17
+ :param cli: An instance of WorktreeCommands containing the alias map used to
18
+ resolve aliases to their corresponding full subcommand names.
19
+ """
20
+ subcommand = sys.argv[1] if len(sys.argv) > 1 else None
21
+ if subcommand is not None and subcommand in cli._alias_map.keys():
22
+ sys.argv[1] = cli._alias_map[subcommand]
23
+
24
+
25
+ def replace_verbose():
26
+ """
27
+ Parses command-line arguments to determine if the verbose mode is enabled.
28
+
29
+ This function searches arguments before the passthrough separator (`--`) for
30
+ verbose flags (`-v` and `--verbose`). Matching flags are removed from
31
+ `sys.argv`, and verbose mode is considered enabled.
32
+
33
+ :return: A boolean indicating whether verbose mode is enabled.
34
+ """
35
+ is_verbose = False
36
+
37
+ passthrough_index = sys.argv.index("--") if "--" in sys.argv else len(sys.argv)
38
+ kept_args = [sys.argv[0]]
39
+
40
+ for arg in sys.argv[1:passthrough_index]:
41
+ if arg in ("-v", "--verbose"):
42
+ is_verbose = True
43
+ continue
44
+ kept_args.append(arg)
45
+
46
+ if passthrough_index < len(sys.argv):
47
+ kept_args.extend(sys.argv[passthrough_index:])
48
+
49
+ sys.argv[:] = kept_args
50
+ return is_verbose
51
+
52
+
53
+ def main():
54
+ """CLI tool for managing Git worktrees"""
55
+ is_verbose = replace_verbose()
56
+ component = WorktreeCommands(verbose=is_verbose)
57
+ replace_alias(component)
58
+
59
+ try:
60
+ # we do not pass `name` here, because the CLI allows using an alias for the command. if name
61
+ # was passed, then using `worktree -- --completion` would use the wrong name for completion
62
+ fire.Fire(component=component)
63
+ except Exception as e:
64
+ if is_verbose:
65
+ raise
66
+ component._logger.error(f"Command failed: {str(e)}")
67
+ sys.exit(1)
treefort/command.py ADDED
@@ -0,0 +1,26 @@
1
+ from treefort.context import Context
2
+ from treefort.runtime import Runtime
3
+
4
+
5
+ class BaseCommand:
6
+ def __init__(self):
7
+ pass
8
+
9
+
10
+ class Command(BaseCommand):
11
+ _name: str
12
+ _aliases: list[str] = []
13
+
14
+ def __init__(self, runtime: Runtime):
15
+ super().__init__()
16
+ self._runtime = runtime
17
+
18
+ @property
19
+ def _context(self) -> Context:
20
+ """Convenience access to the context"""
21
+ return self._runtime.context
22
+
23
+ @property
24
+ def _logger(self):
25
+ """Convenience access to the logger"""
26
+ return self._runtime.logger
File without changes
@@ -0,0 +1,148 @@
1
+ import re
2
+ from functools import cached_property
3
+
4
+ from treefort.command import Command
5
+ from treefort.errors import BranchInputError, CommandError, RemoteNotFoundError, RemoteUsageError
6
+ from treefort.hooks import Hook
7
+ from treefort.runtime import Runtime
8
+ from treefort.utils import normalize_worktree_name
9
+
10
+ URL_RE = re.compile(r"^https://github\.com/[a-z0-9-]+/[\w.-]+/pull/\d+$", re.IGNORECASE)
11
+ BRANCH_RE = re.compile(r"^[\w.-/]+$", re.IGNORECASE)
12
+
13
+
14
+ class CheckoutInput:
15
+ """Checkout command input abstraction"""
16
+
17
+ def __init__(self, runtime: Runtime, branch_or_pr: str, remote_name: str | None = None):
18
+ self.runtime = runtime
19
+ self.branch_or_pr = branch_or_pr
20
+ self.remote_name = remote_name
21
+
22
+ def validate(self):
23
+ if (
24
+ not self.branch_or_pr.isdigit()
25
+ and not URL_RE.match(self.branch_or_pr)
26
+ and not BRANCH_RE.match(self.branch_or_pr)
27
+ ):
28
+ raise BranchInputError(
29
+ "Couldn't parse input. Must be branch name, PR number or PR URL."
30
+ )
31
+
32
+ if self.remote_name is not None and (
33
+ self.branch_or_pr.isdigit() or URL_RE.match(self.branch_or_pr)
34
+ ):
35
+ raise RemoteUsageError("Remote name isn't allowed with PR number or PR URL.")
36
+
37
+ @cached_property
38
+ def owner(self) -> str:
39
+ """The Github owner of the repository"""
40
+ config = self.runtime.context.get_config()
41
+ if (
42
+ self.branch_or_pr.isdigit()
43
+ or self.branch_or_pr.startswith(config.url)
44
+ or not self.remote_name
45
+ ):
46
+ return config.owner
47
+ elif URL_RE.match(self.branch_or_pr):
48
+ return self.branch_or_pr.replace("https://github.com/", "").split("/", 1)[0]
49
+
50
+ remote = self.runtime.get_remote(name=self.remote_name)
51
+ if not remote:
52
+ raise RemoteNotFoundError("Couldn't find matching remote for --remote input")
53
+
54
+ return remote.uri.replace("https://github.com/", "").split("/", 1)[0]
55
+
56
+ @cached_property
57
+ def remote(self) -> str:
58
+ """The local name of the remote to use"""
59
+ if self.remote_name:
60
+ return self.remote_name
61
+ remote = self.runtime.get_remote(owner_name=self.owner)
62
+ if not remote:
63
+ raise RemoteNotFoundError("Couldn't find matching remote for PR")
64
+ return remote.name
65
+
66
+ @cached_property
67
+ def pr_number(self) -> str | None:
68
+ """The PR number, if applicable"""
69
+ if self.branch_or_pr.isdigit():
70
+ return self.branch_or_pr
71
+
72
+ if URL_RE.match(self.branch_or_pr):
73
+ _, pr_number = self.branch_or_pr.rsplit("/", 1)
74
+ return pr_number
75
+
76
+ return None
77
+
78
+ @cached_property
79
+ def worktree_name(self) -> str:
80
+ """The name of the worktree to create"""
81
+ if self.pr_number:
82
+ config = self.runtime.context.get_config()
83
+ pr_status = self.runtime.gh.pr_status(
84
+ self.pr_number, owner_repo=f"{self.owner}/{config.name}"
85
+ )
86
+ return pr_status["headRefName"]
87
+
88
+ return self.branch_or_pr
89
+
90
+
91
+ class CheckoutCommand(Command):
92
+ _name = "checkout"
93
+
94
+ def __call__(self, branch_or_pr: str, remote: str | None = None, yes: bool = False):
95
+ """
96
+ Checkout an existing branch or PR as a worktree.
97
+
98
+ This accepts a PR number which will be looked up on the GitHub repository the project was
99
+ initialized with. It will also accept a GitHub PR URL. Lastly, it will accept a branch
100
+ name, if the branch is already tracked in your local repository. You may specify a remote
101
+ to fetch the latest version of the branch with `--remote`, and that flag only works when
102
+ passing a branch name.
103
+
104
+ Examples:
105
+ treefort checkout 1234
106
+ treefort checkout https://github.com/octo/repo/pull/1234
107
+ treefort checkout my-local-branch
108
+ treefort checkout a-branch --remote upstream
109
+
110
+ :param branch_or_pr: The branch, PR number, or PR URL to create as a worktree
111
+ :param remote: If `branch_or_pr` is a branch, this may be set to choose the remote to use
112
+ :param yes: Perform any action automatically without asking (execute new or modified hooks)
113
+ :type yes: bool
114
+ """
115
+ self._context.assert_within_project()
116
+
117
+ inpt = CheckoutInput(self._runtime, str(branch_or_pr), remote_name=remote)
118
+ inpt.validate()
119
+
120
+ if inpt.pr_number:
121
+ self._runtime.git.fetch(inpt.remote, f"pull/{inpt.pr_number}/head:{inpt.worktree_name}")
122
+ else:
123
+ # TODO: is this good to detect if it's local only?
124
+ try:
125
+ self._runtime.git.fetch(inpt.remote, f"{inpt.worktree_name}:{inpt.worktree_name}")
126
+ except (CommandError, OSError):
127
+ self._logger.warning("Ignoring git fetch error, attempting to open worktree")
128
+ pass
129
+
130
+ normalized_name = normalize_worktree_name(inpt.worktree_name)
131
+
132
+ with self._context.use(self._context.project_dir):
133
+ self._runtime.hooks.fire(
134
+ Hook.pre_checkout,
135
+ inpt.worktree_name,
136
+ f"{inpt.remote}/{inpt.worktree_name}",
137
+ normalized_name,
138
+ bypass_allowlist=yes,
139
+ )
140
+ self._runtime.git.open_worktree(inpt.worktree_name)
141
+ self._runtime.templates.copy(inpt.worktree_name)
142
+ self._runtime.hooks.fire(
143
+ Hook.post_checkout,
144
+ inpt.worktree_name,
145
+ f"{inpt.remote}/{inpt.worktree_name}",
146
+ normalized_name,
147
+ bypass_allowlist=yes,
148
+ )
@@ -0,0 +1,64 @@
1
+ from treefort.command import Command
2
+ from treefort.hooks import Hook
3
+ from treefort.utils import normalize_worktree_name
4
+
5
+
6
+ class CreateCommand(Command):
7
+ _name = "create"
8
+
9
+ def __call__(self, worktree_name: str, *base_ref: str | None, yes: bool = False):
10
+ """
11
+ Create a new worktree in the current project.
12
+
13
+ This will add a new directory to your project directory with the name provided to
14
+ `worktree_name`. By default, the worktree branch will be based off the default GitHub
15
+ repository branch. To base it off another branch, you may use specify it as the second
16
+ argument. If the branch you want to base off contains a slash (`branch/name`), you must also
17
+ specify prefix it with the remote name and a slash, e.g. `remote/branch/name`.
18
+
19
+ Examples:
20
+ treefort create testing-create
21
+ treefort create testing-create main
22
+ treefort create testing-create upstream/main
23
+ treefort create testing-create upstream/some/other/branch
24
+
25
+ :param worktree_name: The name of the worktree
26
+ :param base_ref: The base reference to create the worktree from
27
+ :param yes: Perform any action automatically without asking (execute new or modified hooks)
28
+ :type yes: bool
29
+ """
30
+ base_ref = base_ref[0] if base_ref else None
31
+ self._context.assert_within_project()
32
+ git_remote_name = None
33
+
34
+ if base_ref is None:
35
+ project_config = self._context.get_config()
36
+ base_ref = project_config.default_branch
37
+ elif "/" in base_ref:
38
+ git_remote_name, base_ref = base_ref.split("/", 1)
39
+
40
+ if git_remote_name is None:
41
+ git_remote = self._runtime.get_default_remote()
42
+ git_remote_name = git_remote.name
43
+
44
+ self._runtime.git.fetch(git_remote_name)
45
+
46
+ normalized_name = normalize_worktree_name(worktree_name)
47
+
48
+ with self._context.use(self._context.project_dir):
49
+ self._runtime.hooks.fire(
50
+ Hook.pre_create,
51
+ worktree_name,
52
+ f"{git_remote_name}/{base_ref}",
53
+ normalized_name,
54
+ bypass_allowlist=yes,
55
+ )
56
+ self._runtime.git.add_worktree(worktree_name, f"{git_remote_name}/{base_ref}")
57
+ self._runtime.templates.copy(worktree_name)
58
+ self._runtime.hooks.fire(
59
+ Hook.post_create,
60
+ worktree_name,
61
+ f"{git_remote_name}/{base_ref}",
62
+ normalized_name,
63
+ bypass_allowlist=yes,
64
+ )
@@ -0,0 +1,164 @@
1
+ import re
2
+ from urllib import parse
3
+
4
+ from treefort.command import Command
5
+ from treefort.config import RepositoryConfig
6
+ from treefort.errors import ProjectExistsError, RepositoryPathError
7
+ from treefort.hooks import Hook, HookExists
8
+ from treefort.templates import TemplateExists
9
+
10
+ JUST_PATH_RE = re.compile(r"^[\w\-_]+/[\w\-_]+$")
11
+ GIT_EXT_RE = re.compile(r"\.git$")
12
+
13
+
14
+ def normalize(repo: str):
15
+ if not repo.startswith("http") and not repo.startswith("git@"):
16
+ if not JUST_PATH_RE.match(repo):
17
+ raise RepositoryPathError("Invalid repository path format. Expected: {owner}/{repo}")
18
+ repo = f"https://github.com/{repo}"
19
+
20
+ if not repo.endswith(".git"):
21
+ repo += ".git"
22
+
23
+ return repo
24
+
25
+
26
+ class RepositoryTarget:
27
+ def __init__(self, repo: str, destination_dir: str | None = None):
28
+ self.uri = normalize(repo)
29
+ self._destination_dir = destination_dir
30
+
31
+ def validate(self):
32
+ path_parts = self.path.split("/")
33
+ if len(path_parts) != 2:
34
+ raise RepositoryPathError(f"Invalid repository path: {self.path}")
35
+
36
+ @property
37
+ def path(self):
38
+ if self.uri.startswith("http"):
39
+ return parse.urlparse(self.uri).path.lstrip("/")
40
+ # must be ssh
41
+ _, path = self.uri.split(":")
42
+ return path.lstrip("/")
43
+
44
+ @property
45
+ def owner(self):
46
+ return self.path.split("/")[0]
47
+
48
+ @property
49
+ def name(self):
50
+ return re.sub(GIT_EXT_RE, "", self.path.split("/")[1])
51
+
52
+ @property
53
+ def destination_dir(self):
54
+ if self._destination_dir is None:
55
+ return self.name
56
+ return self._destination_dir
57
+
58
+
59
+ class InitCommand(Command):
60
+ """Initialize a project for use with treefort"""
61
+
62
+ _name = "init"
63
+
64
+ def __call__(self, repo: str, *destination_dir: str | None, yes: bool = False):
65
+ """
66
+ Initialize a project for using treefort with it, by cloning the project and configuring
67
+ it to be a bare git repo.
68
+
69
+ This command creates a suitable structure for worktree directories within the project
70
+ directory. It accepts various input formats, but it must be a GitHub repository, since this
71
+ uses `gh` to gather additional information about the project.
72
+
73
+ Examples:
74
+ treefort init https://github.com/bjester/treefort.git
75
+ treefort init ssh@github.com:bjester/treefort.git
76
+ treefort init bjester/treefort
77
+ treefort init bjester/treefort treefort-second
78
+
79
+ :param repo: The URI, or Github 'owner/repo', to clone
80
+ :type repo: str
81
+ :param destination_dir: The destination directory to clone the project into.
82
+ :type destination_dir: str
83
+ :param yes: Perform any action automatically without asking (execute new or modified hooks)
84
+ :type yes: bool
85
+ """
86
+ destination_dir = destination_dir[0] if destination_dir else None
87
+ repo_target = RepositoryTarget(repo, destination_dir=destination_dir)
88
+ repo_target.validate()
89
+
90
+ project_dir = self._context.cwd / repo_target.destination_dir
91
+
92
+ if project_dir.exists():
93
+ # this would be problematic!
94
+ raise ProjectExistsError(f"Project directory {project_dir} already exists")
95
+
96
+ project_dir.mkdir(parents=True, exist_ok=True)
97
+
98
+ with self._context.use(project_dir):
99
+ self._runtime.hooks.fire(
100
+ Hook.pre_init,
101
+ repo_target.uri,
102
+ project_dir,
103
+ skip_project=True,
104
+ bypass_allowlist=yes,
105
+ )
106
+ self._runtime.git.clone(repo_target.uri, ".bare")
107
+
108
+ with (project_dir / ".git").open("w", encoding="utf-8") as f:
109
+ f.write("gitdir: ./.bare")
110
+
111
+ self._context.reset_properties()
112
+ self._runtime.git.config("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
113
+ self._runtime.git.fetch()
114
+ repo_data = self._runtime.gh.repo_status()
115
+ config = RepositoryConfig()
116
+ config.update(
117
+ default_branch=repo_data["defaultBranchRef"]["name"],
118
+ owner=repo_data["owner"]["login"],
119
+ name=repo_data["name"],
120
+ url=repo_data["url"],
121
+ is_private=repo_data["isPrivate"],
122
+ )
123
+
124
+ self._context.config_dir.mkdir(parents=True, exist_ok=True)
125
+ with (self._context.config_dir / "config.json").open("w", encoding="utf-8") as f:
126
+ config.save(f)
127
+
128
+ self._add_templates(config)
129
+ self._add_hooks(config)
130
+
131
+ self._runtime.hooks.fire(
132
+ Hook.post_init,
133
+ repo_target.uri,
134
+ project_dir,
135
+ bypass_allowlist=yes,
136
+ )
137
+
138
+ def _add_hooks(self, config):
139
+ """Checks whether the repo has hooks in the default branch and copies them"""
140
+ for hook in Hook:
141
+ # check to see if repo has a hook
142
+ if not any(self._runtime.git.ls_tree(config.default_branch, hook.git_path)):
143
+ continue
144
+
145
+ # copy it
146
+ try:
147
+ with self._runtime.hooks.add(hook) as f:
148
+ for line in self._runtime.git.cat_file(config.default_branch, hook.git_path):
149
+ f.write(f"{line}\n")
150
+ except HookExists as e:
151
+ self._logger.warning(f"Skipping due to error: {str(e)}")
152
+
153
+ def _add_templates(self, config):
154
+ """Checks whether the repo has templates in the default branch and copies them"""
155
+ # check to see if repo has templates
156
+ for template_ls in self._runtime.git.ls_tree(config.default_branch, ".treefort/templates"):
157
+ template_file = template_ls.split("\t")[1].strip('"')
158
+ # copy it
159
+ try:
160
+ with self._runtime.templates.add(template_file) as f:
161
+ for line in self._runtime.git.cat_file(config.default_branch, template_file):
162
+ f.write(f"{line}\n")
163
+ except TemplateExists as e:
164
+ self._logger.warning(f"Skipping due to error: {str(e)}")
@@ -0,0 +1,122 @@
1
+ import os
2
+ import shutil
3
+ import stat
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from treefort.command import Command
8
+
9
+
10
+ class InstallCommand(Command):
11
+ _name = "install"
12
+
13
+ def __call__(
14
+ self,
15
+ alias: str | None = None,
16
+ gh_ext: bool = False,
17
+ path_bin: bool = False,
18
+ force: bool = False,
19
+ ):
20
+ """Installs treefort as a GitHub CLI extension or to the user's PATH.
21
+
22
+ Without specifying options, the install command will ask you what to do.
23
+
24
+ :param alias: An alternate name for the extension or path command.
25
+ :param gh_ext: Install as a GitHub CLI extension.
26
+ :param path_bin: Install to the user's PATH.
27
+ :param force: Overwrite existing files.
28
+ """
29
+ target_name = alias or "treefort"
30
+ gh_ext, path_bin = self._ask(target_name, gh_ext, path_bin)
31
+
32
+ current_script_path = Path(sys.argv[0]).resolve()
33
+ should_link = False
34
+
35
+ # unless the script is a self-contained executable, link it instead of copy
36
+ if current_script_path.suffix == ".py":
37
+ should_link = True
38
+ elif not current_script_path.suffix and str(current_script_path).endswith(
39
+ ".venv/bin/treefort"
40
+ ):
41
+ should_link = True
42
+
43
+ target_paths = []
44
+
45
+ if gh_ext:
46
+ # gh cli expects that it has `gh-` prefix
47
+ ext_name = target_name
48
+ if not ext_name.startswith("gh-"):
49
+ ext_name = f"gh-{target_name}"
50
+ target_paths.append(
51
+ Path("~/").expanduser()
52
+ / ".local"
53
+ / "share"
54
+ / "gh"
55
+ / "extensions"
56
+ / ext_name
57
+ / ext_name
58
+ )
59
+
60
+ if path_bin:
61
+ path_dirs = os.getenv("PATH", "").split(os.pathsep)
62
+ # Common user bin directories
63
+ preferred_install_dirs = [
64
+ Path.home() / ".local" / "bin",
65
+ Path.home() / "bin",
66
+ ]
67
+
68
+ install_dir = next((d for d in preferred_install_dirs if str(d) in path_dirs), None)
69
+
70
+ if not install_dir:
71
+ self._logger.error("Could not find a suitable user binary directory in your PATH.")
72
+ sys.exit(1)
73
+
74
+ target_paths.append(install_dir / target_name)
75
+
76
+ self._run(current_script_path, target_paths, should_link, force)
77
+
78
+ def _run(
79
+ self,
80
+ current_script_path: Path,
81
+ target_paths: list[Path],
82
+ should_link: bool,
83
+ force: bool,
84
+ ):
85
+ for target_path in target_paths:
86
+ if target_path.exists() or target_path.is_symlink():
87
+ if not force:
88
+ self._logger.warning(
89
+ f"Path already exists, use --force to overwrite: {target_path}"
90
+ )
91
+ sys.exit(1)
92
+ target_path.unlink(True)
93
+
94
+ target_path.parent.mkdir(parents=True, exist_ok=True)
95
+
96
+ if should_link:
97
+ target_path.symlink_to(current_script_path)
98
+ else:
99
+ shutil.copy(current_script_path, target_path)
100
+ target_path.chmod(target_path.stat().st_mode | stat.S_IEXEC)
101
+ self._logger.info(f"Installed to {target_path}")
102
+
103
+ def _ask(self, target_name: str, gh_ext: bool, path_bin: bool) -> tuple[bool, bool]:
104
+ # ask user what path they would like to use for installation, without these args
105
+ if not gh_ext and not path_bin:
106
+ self._logger.info("Tip: Re-run this with --alias to change the name\n")
107
+ self._logger.info("Which method do you want to use?")
108
+ self._logger.info(
109
+ f"\t1. As a GitHub CLI (gh) extension: gh {target_name.replace('gh-', '')}"
110
+ )
111
+ self._logger.info(f"\t2. In your PATH: {target_name}\n")
112
+
113
+ choice = input("Your choice (1, 2): ")
114
+ if choice not in ("1", "2"):
115
+ self._logger.error("Invalid choice")
116
+ sys.exit(1)
117
+
118
+ if choice == "1":
119
+ gh_ext = True
120
+ elif choice == "2":
121
+ path_bin = True
122
+ return gh_ext, path_bin
@@ -0,0 +1,42 @@
1
+ from treefort.command import Command
2
+ from treefort.errors import WorktreeNotFoundError
3
+ from treefort.hooks import Hook
4
+ from treefort.utils import normalize_worktree_name
5
+
6
+
7
+ class RemoveCommand(Command):
8
+ """Remove a worktree from the current project"""
9
+
10
+ _name = "remove"
11
+ _aliases = ["rm"]
12
+
13
+ def __call__(self, worktree_name: str, force: bool = False, yes: bool = False):
14
+ """
15
+ Remove a worktree from the current project that was added with `create` or `checkout`.
16
+
17
+ If git detects the worktree has commits that are unmerged, then it will refuse to delete it.
18
+ You may use `--force` to passthrough `--force` to git and force the worktree's deletion.
19
+
20
+ Examples:
21
+ treefort remove testing-create
22
+ treefort remove testing-create --force
23
+
24
+ :param worktree_name: The name of the worktree to remove
25
+ :param force: Whether to force the removal of the worktree, if it's unmerged
26
+ :param yes: Perform any action automatically without asking (execute new or modified hooks)
27
+ :type yes: bool
28
+ """
29
+ project_dir = self._context.project_dir
30
+ if not (project_dir / worktree_name).exists():
31
+ raise WorktreeNotFoundError(f"Worktree {worktree_name} does not exist")
32
+
33
+ normalized_name = normalize_worktree_name(worktree_name)
34
+
35
+ with self._context.use(project_dir):
36
+ self._runtime.hooks.fire(
37
+ Hook.pre_remove, worktree_name, normalized_name, bypass_allowlist=yes
38
+ )
39
+ self._runtime.git.remove_worktree(worktree_name, force=force)
40
+ self._runtime.hooks.fire(
41
+ Hook.post_remove, worktree_name, normalized_name, bypass_allowlist=yes
42
+ )