git-worktrees 0.1.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.
- git_worktrees-0.1.0.dist-info/METADATA +335 -0
- git_worktrees-0.1.0.dist-info/RECORD +18 -0
- git_worktrees-0.1.0.dist-info/WHEEL +4 -0
- git_worktrees-0.1.0.dist-info/entry_points.txt +11 -0
- git_worktrees-0.1.0.dist-info/licenses/LICENSE +201 -0
- worktrees/__init__.py +8 -0
- worktrees/cli.py +752 -0
- worktrees/forge.py +115 -0
- worktrees/git.py +302 -0
- worktrees/layout.py +52 -0
- worktrees/merged.py +103 -0
- worktrees/new_branch.py +79 -0
- worktrees/pick.py +105 -0
- worktrees/prune.py +170 -0
- worktrees/repo.py +323 -0
- worktrees/rotate.py +142 -0
- worktrees/verdicts.py +161 -0
- worktrees/worktree.py +221 -0
worktrees/rotate.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Start the next branch in a series, or catch the head branch up.
|
|
2
|
+
|
|
3
|
+
`new-branch` names a branch. This one works out the name, from the branch you
|
|
4
|
+
are standing on, and starts it. Two commands rather than one with an optional
|
|
5
|
+
argument: naming and continuing are different jobs, and a command doing both
|
|
6
|
+
needs an "and" in its description.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import date
|
|
15
|
+
|
|
16
|
+
from . import repo as R
|
|
17
|
+
from .git import git
|
|
18
|
+
from .new_branch import Refusal, short_sha, switch_create
|
|
19
|
+
|
|
20
|
+
# <stem>-YYYY-MM-DD_NNN. Three digits, so the sequence cannot be read as
|
|
21
|
+
# another field of the date the way a two-digit one beside -09-10 can.
|
|
22
|
+
SUFFIX = re.compile(r"-\d{4}-\d{2}-\d{2}_\d{3}$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@git("merge --ff-only $ref", mutates=True)
|
|
26
|
+
def merge_ff_only(ref: str) -> None:
|
|
27
|
+
"""Never a rebase: rewriting local commits on the head branch is the
|
|
28
|
+
class this tooling refuses everywhere else."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@git("log --oneline --no-decorate $range")
|
|
32
|
+
def log_oneline(range: str) -> None:
|
|
33
|
+
"""The commits in a range, for saying which ones are in the way."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Rotated:
|
|
38
|
+
branch: str
|
|
39
|
+
stem: str
|
|
40
|
+
base: str # the full ref
|
|
41
|
+
sha: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class CaughtUp:
|
|
46
|
+
branch: str
|
|
47
|
+
at: str # the ref it now matches
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def stem(branch: str) -> str:
|
|
51
|
+
"""The branch with any suffix a previous rotation added taken off.
|
|
52
|
+
|
|
53
|
+
So four rotations in a day give four siblings rather than one name
|
|
54
|
+
carrying four suffixes.
|
|
55
|
+
"""
|
|
56
|
+
return SUFFIX.sub("", branch)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def next_name(
|
|
60
|
+
base_stem: str,
|
|
61
|
+
remote: str,
|
|
62
|
+
today: str | None = None,
|
|
63
|
+
repo: str | os.PathLike[str] | None = None,
|
|
64
|
+
) -> str:
|
|
65
|
+
"""`<stem>-YYYY-MM-DD_NNN` at the first NNN free today.
|
|
66
|
+
|
|
67
|
+
Free means free here and on the remote: a number nothing holds locally
|
|
68
|
+
but the remote already carries is not free, or two people rotate into the
|
|
69
|
+
same name.
|
|
70
|
+
"""
|
|
71
|
+
day = today or date.today().isoformat()
|
|
72
|
+
for n in range(1, 1000):
|
|
73
|
+
name = f"{base_stem}-{day}_{n:03d}"
|
|
74
|
+
if R.ref_exists(f"refs/heads/{name}", repo=repo):
|
|
75
|
+
continue
|
|
76
|
+
if remote and R.ref_exists(f"refs/remotes/{remote}/{name}", repo=repo):
|
|
77
|
+
continue
|
|
78
|
+
return name
|
|
79
|
+
raise Refusal(f"every number from 001 to 999 is taken for {base_stem} on {day}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def rotate(
|
|
83
|
+
fetch: bool,
|
|
84
|
+
warn: object = None,
|
|
85
|
+
repo: str | os.PathLike[str] | None = None,
|
|
86
|
+
) -> Rotated | CaughtUp:
|
|
87
|
+
"""Start the next branch after this one, or catch the head branch up."""
|
|
88
|
+
branch = R.current_branch(repo=repo).out.strip()
|
|
89
|
+
if not branch:
|
|
90
|
+
raise Refusal(
|
|
91
|
+
"HEAD is detached, so there is no branch to name the next one "
|
|
92
|
+
"after; 'worktrees new-branch <name>' names one"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
remote = R.remote(repo=repo)
|
|
96
|
+
if fetch and remote and not R.fetch(remote, repo=repo) and callable(warn):
|
|
97
|
+
warn(f"{remote} could not be fetched; working from what is already here")
|
|
98
|
+
|
|
99
|
+
base, warning = R.head_ref(remote, online=fetch, repo=repo)
|
|
100
|
+
if warning and callable(warn):
|
|
101
|
+
warn(warning)
|
|
102
|
+
if not base:
|
|
103
|
+
hint = remote or "origin"
|
|
104
|
+
raise Refusal(
|
|
105
|
+
"cannot tell which branch this repository branches from; record it "
|
|
106
|
+
f"with: git remote set-head {hint} --auto"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
head_name = R.ref_name(base)
|
|
110
|
+
if remote:
|
|
111
|
+
head_name = head_name.removeprefix(remote + "/")
|
|
112
|
+
|
|
113
|
+
if branch == head_name:
|
|
114
|
+
return _catch_up(branch, base, repo=repo)
|
|
115
|
+
|
|
116
|
+
chosen = next_name(stem(branch), remote, repo=repo)
|
|
117
|
+
sha = short_sha(base, repo=repo)
|
|
118
|
+
switch_create(chosen, base, repo=repo)
|
|
119
|
+
return Rotated(chosen, stem(branch), base, sha.out.strip() if sha else "")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _catch_up(
|
|
123
|
+
branch: str, base: str, repo: str | os.PathLike[str] | None = None
|
|
124
|
+
) -> CaughtUp:
|
|
125
|
+
"""There is no chain to continue from the head branch, so bring it level.
|
|
126
|
+
|
|
127
|
+
--ff-only refuses when the local copy is ahead, which is the answer: those
|
|
128
|
+
commits are a change of their own and belong on a branch.
|
|
129
|
+
"""
|
|
130
|
+
ahead = R.count_between(base, f"refs/heads/{branch}", repo=repo)
|
|
131
|
+
count = int(ahead.out.strip() or 0) if ahead else 0
|
|
132
|
+
if count:
|
|
133
|
+
listed = log_oneline(f"{base}..refs/heads/{branch}", repo=repo)
|
|
134
|
+
lines = "\n".join(f" {line}" for line in listed.lines) if listed else ""
|
|
135
|
+
raise Refusal(
|
|
136
|
+
f"{branch} is {count} commit(s) ahead of {R.ref_name(base)}, so it "
|
|
137
|
+
f"cannot be fast-forwarded:\n{lines}\n"
|
|
138
|
+
"Those commits are a change of their own; "
|
|
139
|
+
"'worktrees new-branch <name>' puts them on one."
|
|
140
|
+
)
|
|
141
|
+
merge_ff_only(base, repo=repo)
|
|
142
|
+
return CaughtUp(branch, R.ref_name(base))
|
worktrees/verdicts.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""One verdict per worktree. Nothing here mutates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import forge
|
|
10
|
+
from . import repo as R
|
|
11
|
+
from .merged import merged_reason
|
|
12
|
+
|
|
13
|
+
# The verdicts, each the instruction it gives. `unknown` is not `keep` with a
|
|
14
|
+
# softer word: "no upstream, so nothing says whether this was pushed" is a
|
|
15
|
+
# different fact from "this is not merged", and a sweep printing them the same
|
|
16
|
+
# way invites somebody to act on the wrong one.
|
|
17
|
+
REMOVE = "remove"
|
|
18
|
+
KEEP = "keep"
|
|
19
|
+
UNKNOWN = "unknown"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Verdict:
|
|
24
|
+
verdict: str
|
|
25
|
+
branch: str # empty when detached
|
|
26
|
+
path: str
|
|
27
|
+
why: str
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def label(self) -> str:
|
|
31
|
+
return self.branch or "(detached)"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def stale(records: list[R.Worktree]) -> list[R.Worktree]:
|
|
35
|
+
"""Records answering for a directory somebody deleted by hand.
|
|
36
|
+
|
|
37
|
+
`git worktree prune` is what clears them, so they are nobody's verdict:
|
|
38
|
+
neither command may propose removing a checkout that is not there.
|
|
39
|
+
"""
|
|
40
|
+
return [w for w in records if "prunable" in w.flags]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def assess(
|
|
44
|
+
records: list[R.Worktree],
|
|
45
|
+
only: str,
|
|
46
|
+
head: str,
|
|
47
|
+
head_branch: str,
|
|
48
|
+
delete_ignored: bool,
|
|
49
|
+
repo: str | os.PathLike[str] | None = None,
|
|
50
|
+
here: str | None = None,
|
|
51
|
+
ask_forge: bool = False,
|
|
52
|
+
) -> list[Verdict]:
|
|
53
|
+
"""Every worktree, in the order `git worktree remove` would refuse them.
|
|
54
|
+
|
|
55
|
+
Proposing something that would then be refused is a bug here, not a
|
|
56
|
+
surprise at the confirmation.
|
|
57
|
+
|
|
58
|
+
`here` is the checkout the caller is standing in, which is kept rather
|
|
59
|
+
than proposed. Pass "" to judge it like any other: `gwr` is asked for one
|
|
60
|
+
by name and steps out of it first, where a listing has no way to.
|
|
61
|
+
"""
|
|
62
|
+
main = R.main_worktree(repo)
|
|
63
|
+
if here is None:
|
|
64
|
+
here = R.toplevel(repo=repo).out.strip()
|
|
65
|
+
head_name = R.ref_name(head)
|
|
66
|
+
|
|
67
|
+
out: list[Verdict] = []
|
|
68
|
+
for wt in records:
|
|
69
|
+
if "bare" in wt.flags or "prunable" in wt.flags or wt.path == main:
|
|
70
|
+
continue
|
|
71
|
+
if only and wt.branch != only:
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
def say(verdict: str, why: str, wt: R.Worktree = wt) -> None:
|
|
75
|
+
out.append(Verdict(verdict, wt.branch, wt.path, why))
|
|
76
|
+
|
|
77
|
+
if here and Path(wt.path).resolve() == Path(here).resolve():
|
|
78
|
+
say(KEEP, "you are standing in it")
|
|
79
|
+
continue
|
|
80
|
+
if "locked" in wt.flags:
|
|
81
|
+
say(KEEP, "it is locked")
|
|
82
|
+
continue
|
|
83
|
+
if wt.branch and wt.branch == head_branch:
|
|
84
|
+
say(KEEP, "it is the head branch")
|
|
85
|
+
continue
|
|
86
|
+
if R.is_dirty(wt.path):
|
|
87
|
+
say(KEEP, "it has uncommitted changes")
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
if not wt.branch:
|
|
91
|
+
# Detached: finished when some ref already reaches the commit,
|
|
92
|
+
# which is the question `git worktree remove` asks of one.
|
|
93
|
+
if R.refs_containing(wt.sha, repo=repo).out.strip():
|
|
94
|
+
say(REMOVE, "its commit is reached by a ref")
|
|
95
|
+
else:
|
|
96
|
+
say(UNKNOWN, f"no ref reaches {wt.sha}")
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
reason = merged_reason(wt.branch, head, repo=repo)
|
|
100
|
+
if not reason and ask_forge:
|
|
101
|
+
reason, verdict = _forge_reason(wt.branch, repo=repo)
|
|
102
|
+
if verdict:
|
|
103
|
+
say(verdict, reason)
|
|
104
|
+
continue
|
|
105
|
+
if not reason:
|
|
106
|
+
if R.unpushed_count(wt.branch, repo=repo) is None:
|
|
107
|
+
say(
|
|
108
|
+
UNKNOWN,
|
|
109
|
+
f"not merged into {head_name}, and no upstream says whether "
|
|
110
|
+
"its commits were pushed",
|
|
111
|
+
)
|
|
112
|
+
else:
|
|
113
|
+
say(KEEP, f"not merged into {head_name}")
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
ignored = R.ignored_paths(wt.path)
|
|
117
|
+
if ignored and not delete_ignored:
|
|
118
|
+
say(
|
|
119
|
+
KEEP,
|
|
120
|
+
f"{reason}, but holds {len(ignored)} ignored path(s); "
|
|
121
|
+
"pass --delete-ignored",
|
|
122
|
+
)
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
say(REMOVE, reason)
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _forge_reason(
|
|
130
|
+
branch: str, repo: str | os.PathLike[str] | None = None
|
|
131
|
+
) -> tuple[str, str]:
|
|
132
|
+
"""What the forge says, cross-checked against what it cannot see.
|
|
133
|
+
|
|
134
|
+
Returns (reason, verdict), or ("", "") when the forge said nothing and
|
|
135
|
+
the content probes keep the answer.
|
|
136
|
+
|
|
137
|
+
A merged request speaks for what was pushed. Commits an upstream has not
|
|
138
|
+
got were never in it, and a branch with no upstream at all leaves that
|
|
139
|
+
unknown rather than zero: branches made here do not track, so "no
|
|
140
|
+
upstream" is the normal state for exactly the ones this would otherwise
|
|
141
|
+
reap.
|
|
142
|
+
"""
|
|
143
|
+
request = forge.request_for(branch, repo=repo)
|
|
144
|
+
if request is None or request.state not in ("MERGED", "CLOSED"):
|
|
145
|
+
return "", ""
|
|
146
|
+
|
|
147
|
+
lower = request.state.lower()
|
|
148
|
+
unpushed = R.unpushed_count(branch, repo=repo)
|
|
149
|
+
if unpushed is None:
|
|
150
|
+
return (
|
|
151
|
+
f"its {request.noun} is {lower}, but the branch has no upstream "
|
|
152
|
+
"to have been pushed to",
|
|
153
|
+
UNKNOWN,
|
|
154
|
+
)
|
|
155
|
+
if unpushed:
|
|
156
|
+
return (
|
|
157
|
+
f"its {request.noun} is {lower}, but {unpushed} commit(s) here "
|
|
158
|
+
"are not in it",
|
|
159
|
+
KEEP,
|
|
160
|
+
)
|
|
161
|
+
return f"its {request.noun} #{request.number} is {lower}", REMOVE
|
worktrees/worktree.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Adding, moving and removing a checkout.
|
|
2
|
+
|
|
3
|
+
Each of these lands the caller somewhere, so each prints a destination on
|
|
4
|
+
stdout and nothing else. A binary cannot cd its caller; the shim reads that
|
|
5
|
+
line and does the move.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import layout
|
|
15
|
+
from . import repo as R
|
|
16
|
+
from .git import GitError, Refused, git
|
|
17
|
+
from .new_branch import Refusal, check_ref_format
|
|
18
|
+
from .prune import _prune_empty_parents, branch_delete, worktree_remove
|
|
19
|
+
from .verdicts import REMOVE, Verdict, assess
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@git("worktree add --no-track -b $name -- $dest $base", mutates=True)
|
|
23
|
+
def worktree_add(name: str, dest: str, base: str) -> None:
|
|
24
|
+
"""--no-track, for the reason new-branch has it: a branch off the head
|
|
25
|
+
branch that tracked it would take it as its upstream."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@git("worktree add -- $dest $name", mutates=True)
|
|
29
|
+
def worktree_add_existing(dest: str, name: str) -> None:
|
|
30
|
+
"""A branch that already exists needs no -b, and -b would refuse."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@git("worktree move -- $old $new", mutates=True)
|
|
34
|
+
def worktree_move(old: str, new: str) -> None:
|
|
35
|
+
"""git updates its own bookkeeping, which a mv would leave behind."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@git("branch --move -- $old $new", mutates=True)
|
|
39
|
+
def branch_move(old: str, new: str) -> None:
|
|
40
|
+
"""--move, not --move --force: a rename onto a name that exists is a
|
|
41
|
+
collision to report rather than one to resolve."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Landed:
|
|
46
|
+
"""Where the caller should end up, and what happened to get there."""
|
|
47
|
+
|
|
48
|
+
path: str
|
|
49
|
+
branch: str
|
|
50
|
+
created: bool # False when the branch was already here
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def add(
|
|
54
|
+
name: str,
|
|
55
|
+
base: str = "",
|
|
56
|
+
fetch: bool = True,
|
|
57
|
+
warn: object = None,
|
|
58
|
+
repo: str | os.PathLike[str] | None = None,
|
|
59
|
+
) -> Landed:
|
|
60
|
+
"""Create a worktree for `name` and say where it is."""
|
|
61
|
+
if not check_ref_format(name, repo=repo):
|
|
62
|
+
raise Refusal(f"{name} is not a valid branch name")
|
|
63
|
+
|
|
64
|
+
main = R.main_worktree(repo)
|
|
65
|
+
if not main:
|
|
66
|
+
raise Refusal("not inside a git repository")
|
|
67
|
+
dest = layout.destination(name, main)
|
|
68
|
+
|
|
69
|
+
for wt in R.worktrees(repo):
|
|
70
|
+
if wt.branch == name:
|
|
71
|
+
raise Refusal(f"{name} is already checked out at {wt.path}")
|
|
72
|
+
|
|
73
|
+
if dest.exists():
|
|
74
|
+
owner = layout.owner_of(str(dest), repo=repo)
|
|
75
|
+
if owner:
|
|
76
|
+
raise Refusal(f"{dest} belongs to {owner}, not to this repository")
|
|
77
|
+
raise Refusal(f"{dest} already exists and this repository does not own it")
|
|
78
|
+
|
|
79
|
+
remote = R.remote(repo=repo)
|
|
80
|
+
if fetch and remote and not R.fetch(remote, repo=repo) and callable(warn):
|
|
81
|
+
warn(f"{remote} could not be fetched; branching from what is already here")
|
|
82
|
+
|
|
83
|
+
existing = bool(R.ref_exists(f"refs/heads/{name}", repo=repo))
|
|
84
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
|
|
86
|
+
if existing:
|
|
87
|
+
worktree_add_existing(str(dest), name, repo=repo)
|
|
88
|
+
return Landed(str(dest), name, created=False)
|
|
89
|
+
|
|
90
|
+
start = base or _head(remote, fetch, warn, repo=repo)
|
|
91
|
+
worktree_add(name, str(dest), start, repo=repo)
|
|
92
|
+
return Landed(str(dest), name, created=True)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _head(
|
|
96
|
+
remote: str,
|
|
97
|
+
fetch: bool,
|
|
98
|
+
warn: object,
|
|
99
|
+
repo: str | os.PathLike[str] | None = None,
|
|
100
|
+
) -> str:
|
|
101
|
+
ref, warning = R.head_ref(remote, online=fetch, repo=repo)
|
|
102
|
+
if warning and callable(warn):
|
|
103
|
+
warn(warning)
|
|
104
|
+
if not ref:
|
|
105
|
+
hint = remote or "origin"
|
|
106
|
+
raise Refusal(
|
|
107
|
+
"cannot tell which branch this repository branches from; record it "
|
|
108
|
+
f"with: git remote set-head {hint} --auto"
|
|
109
|
+
)
|
|
110
|
+
return ref
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def move(
|
|
114
|
+
new: str,
|
|
115
|
+
repo: str | os.PathLike[str] | None = None,
|
|
116
|
+
) -> Landed:
|
|
117
|
+
"""Rename this worktree's branch and move the checkout to match.
|
|
118
|
+
|
|
119
|
+
Not a convenience. Renaming the directory you are standing in leaves the
|
|
120
|
+
shell with a stale $PWD and every later command failing, so the caller
|
|
121
|
+
has to be told where to go.
|
|
122
|
+
"""
|
|
123
|
+
if not check_ref_format(new, repo=repo):
|
|
124
|
+
raise Refusal(f"{new} is not a valid branch name")
|
|
125
|
+
|
|
126
|
+
here = R.toplevel(repo=repo).out.strip()
|
|
127
|
+
if not here:
|
|
128
|
+
raise Refusal("not inside a git repository")
|
|
129
|
+
main = R.main_worktree(repo)
|
|
130
|
+
if Path(here).resolve() == Path(main).resolve():
|
|
131
|
+
raise Refusal(
|
|
132
|
+
"this is the main checkout, not a worktree; "
|
|
133
|
+
"git branch --move renames its branch"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
old = R.current_branch(repo=here).out.strip()
|
|
137
|
+
if not old:
|
|
138
|
+
raise Refusal("HEAD is detached here, so there is no branch to rename")
|
|
139
|
+
if old == new:
|
|
140
|
+
raise Refusal(f"this worktree is already on {new}")
|
|
141
|
+
if R.ref_exists(f"refs/heads/{new}", repo=repo):
|
|
142
|
+
raise Refusal(f"{new} is already a branch")
|
|
143
|
+
|
|
144
|
+
dest = layout.destination(new, main)
|
|
145
|
+
if dest.exists():
|
|
146
|
+
raise Refusal(f"{dest} already exists")
|
|
147
|
+
|
|
148
|
+
# The branch first: `worktree move` leaves the branch alone, and a failed
|
|
149
|
+
# move after a rename is recoverable where the reverse loses the name.
|
|
150
|
+
branch_move(old, new, repo=repo)
|
|
151
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
try:
|
|
153
|
+
worktree_move(here, str(dest), repo=repo)
|
|
154
|
+
except (GitError, Refused):
|
|
155
|
+
branch_move(new, old, repo=repo)
|
|
156
|
+
raise
|
|
157
|
+
_prune_empty_parents(Path(here).parent, main)
|
|
158
|
+
return Landed(str(dest), new, created=False)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def remove(
|
|
162
|
+
chosen: Verdict,
|
|
163
|
+
say: object,
|
|
164
|
+
repo: str | os.PathLike[str] | None = None,
|
|
165
|
+
) -> str:
|
|
166
|
+
"""Remove one worktree, and say where to go when it was this one.
|
|
167
|
+
|
|
168
|
+
Returns the main checkout when the caller was standing in what went, and
|
|
169
|
+
an empty string otherwise. Empty means stay put.
|
|
170
|
+
|
|
171
|
+
What goes and what puts it back is printed by the caller, before this is
|
|
172
|
+
reached, so that a run which stops at the confirmation has still said it.
|
|
173
|
+
"""
|
|
174
|
+
here = R.toplevel(repo=repo).out.strip()
|
|
175
|
+
main = R.main_worktree(repo)
|
|
176
|
+
standing_in = bool(here) and Path(here).resolve() == Path(chosen.path).resolve()
|
|
177
|
+
|
|
178
|
+
if standing_in:
|
|
179
|
+
# git refuses to remove the worktree a process is sitting in only
|
|
180
|
+
# sometimes; leaving first makes it always safe and gives the shim a
|
|
181
|
+
# destination to move to.
|
|
182
|
+
os.chdir(main)
|
|
183
|
+
|
|
184
|
+
worktree_remove(chosen.path, repo=main)
|
|
185
|
+
_prune_empty_parents(Path(chosen.path).parent, main)
|
|
186
|
+
if chosen.branch:
|
|
187
|
+
try:
|
|
188
|
+
branch_delete(chosen.branch, repo=main)
|
|
189
|
+
except (GitError, Refused) as exc:
|
|
190
|
+
if callable(say):
|
|
191
|
+
say(f" branch {chosen.branch} kept: {exc}")
|
|
192
|
+
return main if standing_in else ""
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def removable(
|
|
196
|
+
only: str,
|
|
197
|
+
head: str,
|
|
198
|
+
head_branch: str,
|
|
199
|
+
delete_ignored: bool,
|
|
200
|
+
repo: str | os.PathLike[str] | None = None,
|
|
201
|
+
ask_forge: bool = True,
|
|
202
|
+
) -> list[Verdict]:
|
|
203
|
+
"""Every worktree, judging the one you stand in like any other.
|
|
204
|
+
|
|
205
|
+
`gws` keeps it, because a listing cannot step out of a directory on your
|
|
206
|
+
behalf. `gwr` is asked for one by name and does step out, so the rule
|
|
207
|
+
that protects a listing would only hide the answer here.
|
|
208
|
+
"""
|
|
209
|
+
return assess(
|
|
210
|
+
R.worktrees(repo),
|
|
211
|
+
only,
|
|
212
|
+
head,
|
|
213
|
+
head_branch,
|
|
214
|
+
delete_ignored,
|
|
215
|
+
repo=repo,
|
|
216
|
+
here="",
|
|
217
|
+
ask_forge=ask_forge,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
__all__ = ["REMOVE", "Landed", "add", "move", "removable", "remove"]
|