treefort 0.5.0__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 +7 -0
- treefort/__main__.py +4 -0
- treefort/cli.py +67 -0
- treefort/command.py +26 -0
- treefort/commands/__init__.py +0 -0
- treefort/commands/checkout.py +148 -0
- treefort/commands/create.py +64 -0
- treefort/commands/init.py +162 -0
- treefort/commands/install.py +122 -0
- treefort/commands/prune.py +152 -0
- treefort/commands/remove.py +97 -0
- treefort/config.py +361 -0
- treefort/context.py +154 -0
- treefort/errors.py +117 -0
- treefort/gh.py +112 -0
- treefort/git.py +116 -0
- treefort/hooks.py +134 -0
- treefort/logger.py +131 -0
- treefort/main.py +69 -0
- treefort/operator.py +36 -0
- treefort/runtime.py +58 -0
- treefort/subprocess.py +214 -0
- treefort/templates.py +76 -0
- treefort/utils.py +39 -0
- treefort-0.5.0.dist-info/METADATA +332 -0
- treefort-0.5.0.dist-info/RECORD +29 -0
- treefort-0.5.0.dist-info/WHEEL +4 -0
- treefort-0.5.0.dist-info/entry_points.txt +3 -0
- treefort-0.5.0.dist-info/licenses/LICENSE +7 -0
treefort/__init__.py
ADDED
treefort/__main__.py
ADDED
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,162 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from urllib import parse
|
|
3
|
+
|
|
4
|
+
from treefort.command import Command
|
|
5
|
+
from treefort.config import RepositoryConfig, TomlConfigIO
|
|
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(TomlConfigIO.from_path(self._context.config_dir, "config"))
|
|
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.set_config(config)
|
|
125
|
+
|
|
126
|
+
self._add_templates(config)
|
|
127
|
+
self._add_hooks(config)
|
|
128
|
+
|
|
129
|
+
self._runtime.hooks.fire(
|
|
130
|
+
Hook.post_init,
|
|
131
|
+
repo_target.uri,
|
|
132
|
+
project_dir,
|
|
133
|
+
bypass_allowlist=yes,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def _add_hooks(self, config):
|
|
137
|
+
"""Checks whether the repo has hooks in the default branch and copies them"""
|
|
138
|
+
for hook in Hook:
|
|
139
|
+
# check to see if repo has a hook
|
|
140
|
+
if not any(self._runtime.git.ls_tree(config.default_branch, hook.git_path)):
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
# copy it
|
|
144
|
+
try:
|
|
145
|
+
with self._runtime.hooks.add(hook) as f:
|
|
146
|
+
for line in self._runtime.git.cat_file(config.default_branch, hook.git_path):
|
|
147
|
+
f.write(f"{line}\n")
|
|
148
|
+
except HookExists as e:
|
|
149
|
+
self._logger.warning(f"Skipping due to error: {str(e)}")
|
|
150
|
+
|
|
151
|
+
def _add_templates(self, config):
|
|
152
|
+
"""Checks whether the repo has templates in the default branch and copies them"""
|
|
153
|
+
# check to see if repo has templates
|
|
154
|
+
for template_ls in self._runtime.git.ls_tree(config.default_branch, ".treefort/templates"):
|
|
155
|
+
template_file = template_ls.split("\t")[1].strip('"')
|
|
156
|
+
# copy it
|
|
157
|
+
try:
|
|
158
|
+
with self._runtime.templates.add(template_file) as f:
|
|
159
|
+
for line in self._runtime.git.cat_file(config.default_branch, template_file):
|
|
160
|
+
f.write(f"{line}\n")
|
|
161
|
+
except TemplateExists as e:
|
|
162
|
+
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
|