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.
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"]