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/pick.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Choosing one worktree out of the handful this repository has.
|
|
2
|
+
|
|
3
|
+
No fzf. The largest number of linked worktrees in one repository here is
|
|
4
|
+
four, and at that size a numbered prompt reads faster than a fuzzy finder
|
|
5
|
+
and costs no dependency, no spawn, no tty rules and no absent-fzf fallback.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
from collections.abc import Callable, Sequence
|
|
12
|
+
|
|
13
|
+
from .git import Refused
|
|
14
|
+
from .repo import Worktree
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def subsequence(query: str, text: str) -> tuple[int, int] | None:
|
|
18
|
+
"""The one idea worth taking from fzf: the query's letters in order.
|
|
19
|
+
|
|
20
|
+
Returns how tightly they cluster and where they start, so `tst` finds
|
|
21
|
+
`add-tests`. None when they do not all appear.
|
|
22
|
+
"""
|
|
23
|
+
q, t = query.lower(), text.lower()
|
|
24
|
+
first: int | None = None
|
|
25
|
+
last = seen = 0
|
|
26
|
+
for i, ch in enumerate(t):
|
|
27
|
+
if seen < len(q) and ch == q[seen]:
|
|
28
|
+
if first is None:
|
|
29
|
+
first = i
|
|
30
|
+
last, seen = i, seen + 1
|
|
31
|
+
if seen < len(q) or first is None:
|
|
32
|
+
return None
|
|
33
|
+
return last - first, first
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def matches(query: str, worktrees: Sequence[Worktree]) -> list[Worktree]:
|
|
37
|
+
"""Substring first, then subsequence, each group in its own order.
|
|
38
|
+
|
|
39
|
+
A substring hit always beats a subsequence one, so typing more of a name
|
|
40
|
+
never moves it down the list.
|
|
41
|
+
|
|
42
|
+
Substring looks at the branch and the path; subsequence looks at the
|
|
43
|
+
branch alone. Every path here contains `.worktrees`, which supplies a
|
|
44
|
+
`t`, a `w` and an `o` to any query that wants them, so a loose match
|
|
45
|
+
against the path finds everything and means nothing.
|
|
46
|
+
"""
|
|
47
|
+
if not query:
|
|
48
|
+
return list(worktrees)
|
|
49
|
+
q = query.lower()
|
|
50
|
+
exact: list[tuple[int, Worktree]] = []
|
|
51
|
+
loose: list[tuple[int, Worktree]] = []
|
|
52
|
+
for wt in worktrees:
|
|
53
|
+
label, path = wt.label.lower(), wt.path.lower()
|
|
54
|
+
if q in label:
|
|
55
|
+
exact.append((label.index(q), wt))
|
|
56
|
+
continue
|
|
57
|
+
if q in path:
|
|
58
|
+
exact.append((len(label) + path.index(q), wt))
|
|
59
|
+
continue
|
|
60
|
+
rank = subsequence(query, wt.label)
|
|
61
|
+
if rank is not None:
|
|
62
|
+
loose.append((rank[0], wt))
|
|
63
|
+
return [wt for _, wt in exact] + [wt for _, wt in loose]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def choose(
|
|
67
|
+
candidates: Sequence[Worktree],
|
|
68
|
+
show: Callable[[str], None],
|
|
69
|
+
ask: Callable[[str], str] | None = None,
|
|
70
|
+
) -> Worktree | None:
|
|
71
|
+
"""One match takes it outright. Otherwise ask, and None means cancelled.
|
|
72
|
+
|
|
73
|
+
A run whose stdin is not a terminal is refused rather than left to block:
|
|
74
|
+
an agent or a pipe reaching a prompt would hang, and --json answers the
|
|
75
|
+
same question without one.
|
|
76
|
+
"""
|
|
77
|
+
if not candidates:
|
|
78
|
+
return None
|
|
79
|
+
if len(candidates) == 1:
|
|
80
|
+
return candidates[0]
|
|
81
|
+
|
|
82
|
+
if ask is None:
|
|
83
|
+
if not sys.stdin.isatty():
|
|
84
|
+
raise Refused(
|
|
85
|
+
f"{len(candidates)} worktrees match and this is not a terminal; "
|
|
86
|
+
"narrow the query, or use --list or --json"
|
|
87
|
+
)
|
|
88
|
+
ask = _prompt
|
|
89
|
+
|
|
90
|
+
width = max(len(wt.label) for wt in candidates)
|
|
91
|
+
for i, wt in enumerate(candidates, 1):
|
|
92
|
+
show(f"{i:>3} {wt.label:<{width}} {wt.path}")
|
|
93
|
+
answer = ask(f"which? [1-{len(candidates)}, or blank to cancel] ").strip()
|
|
94
|
+
if not answer:
|
|
95
|
+
return None
|
|
96
|
+
if not answer.isdigit() or not 1 <= int(answer) <= len(candidates):
|
|
97
|
+
raise Refused(f"{answer} is not one of 1 to {len(candidates)}")
|
|
98
|
+
return candidates[int(answer) - 1]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _prompt(text: str) -> str:
|
|
102
|
+
"""The question on stderr, the answer from stdin, so stdout stays data."""
|
|
103
|
+
sys.stderr.write(text)
|
|
104
|
+
sys.stderr.flush()
|
|
105
|
+
return sys.stdin.readline()
|
worktrees/prune.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Removing the worktrees `status` marked `remove`, and nothing else."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import repo as R
|
|
11
|
+
from .git import GitError, Refused, git
|
|
12
|
+
from .verdicts import REMOVE, Verdict
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@git("worktree prune", mutates=True)
|
|
16
|
+
def worktree_prune() -> None:
|
|
17
|
+
"""Clear git's bookkeeping for worktrees somebody deleted by hand."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@git("worktree remove -- $path", mutates=True)
|
|
21
|
+
def worktree_remove(path: str) -> None:
|
|
22
|
+
"""Drop a checkout. Refuses on its own when the worktree is dirty."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@git("branch -d -- $branch", mutates=True)
|
|
26
|
+
def branch_delete(branch: str) -> None:
|
|
27
|
+
"""-d, never -D: git's own proof of merge is the only proof accepted."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@git("rev-parse --verify $ref", ok=(0, 128))
|
|
31
|
+
def sha_of(ref: str) -> None:
|
|
32
|
+
"""The sha a ref names, for the line that puts it back."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@git("cat-file -t $sha", ok=(0, 128))
|
|
36
|
+
def object_type(sha: str) -> None:
|
|
37
|
+
"""What kind of object a sha is. Every printed sha must be a commit."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def removable(verdicts: list[Verdict]) -> list[Verdict]:
|
|
41
|
+
"""The `remove` rows, which is the whole of what prune may touch.
|
|
42
|
+
|
|
43
|
+
The one place the set is derived. `status` prints these as `remove` and
|
|
44
|
+
`prune` removes these, so the two cannot drift.
|
|
45
|
+
"""
|
|
46
|
+
return [v for v in verdicts if v.verdict == REMOVE]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def restore_line(branch: str, repo: str | os.PathLike[str] | None = None) -> str:
|
|
50
|
+
"""The command that puts a branch back, or '' when no sha can be proved.
|
|
51
|
+
|
|
52
|
+
The sha is checked to be a commit. The bug's signature is a correctly
|
|
53
|
+
shaped restore line carrying the wrong sha, so the check is on the object
|
|
54
|
+
and not on the sentence.
|
|
55
|
+
"""
|
|
56
|
+
if not branch:
|
|
57
|
+
return ""
|
|
58
|
+
sha = sha_of(f"refs/heads/{branch}", repo=repo)
|
|
59
|
+
text = sha.out.strip() if sha else ""
|
|
60
|
+
if not text:
|
|
61
|
+
return ""
|
|
62
|
+
kind = object_type(text, repo=repo)
|
|
63
|
+
if not kind or kind.out.strip() != "commit":
|
|
64
|
+
return ""
|
|
65
|
+
return f"git branch {branch} {text}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def plan(
|
|
69
|
+
go: list[Verdict],
|
|
70
|
+
delete_ignored: bool,
|
|
71
|
+
say: Callable[[str], None],
|
|
72
|
+
repo: str | os.PathLike[str] | None = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Print what would go, and what puts it back, before anything does.
|
|
75
|
+
|
|
76
|
+
--quiet and --yes do not silence this.
|
|
77
|
+
"""
|
|
78
|
+
for v in go:
|
|
79
|
+
say(f"{v.label} {v.path}")
|
|
80
|
+
restore = restore_line(v.branch, repo=repo)
|
|
81
|
+
if restore:
|
|
82
|
+
say(f" restore with: {restore}")
|
|
83
|
+
if delete_ignored:
|
|
84
|
+
for path in R.ignored_paths(v.path):
|
|
85
|
+
say(f" deleting ignored, unrecoverable: {path}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _prompt(text: str) -> str:
|
|
89
|
+
"""The question on stderr, the answer from stdin.
|
|
90
|
+
|
|
91
|
+
Not `input`, which puts its prompt on stdout: stdout carries the result
|
|
92
|
+
and nothing else, so `--json` stays parseable in every mode.
|
|
93
|
+
"""
|
|
94
|
+
sys.stderr.write(text)
|
|
95
|
+
sys.stderr.flush()
|
|
96
|
+
return sys.stdin.readline()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def confirm(count: int, ask: Callable[[str], str] | None = None) -> bool:
|
|
100
|
+
"""Ask before removing. Anything but yes is no.
|
|
101
|
+
|
|
102
|
+
A run whose stdin is not a terminal cannot answer, so it is refused
|
|
103
|
+
rather than left to block: an agent or a pipe reaches this and would
|
|
104
|
+
otherwise hang forever holding the repository's worktrees.
|
|
105
|
+
"""
|
|
106
|
+
if ask is None:
|
|
107
|
+
if not sys.stdin.isatty():
|
|
108
|
+
raise Refused(
|
|
109
|
+
f"not a terminal, so nothing can answer for the {count} above; "
|
|
110
|
+
"pass --yes to remove them"
|
|
111
|
+
)
|
|
112
|
+
ask = _prompt
|
|
113
|
+
return ask(f"remove {count} worktree(s)? [y/N] ").strip().lower() in ("y", "yes")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def sweep(
|
|
117
|
+
go: list[Verdict],
|
|
118
|
+
delete_ignored: bool,
|
|
119
|
+
say: Callable[[str], None],
|
|
120
|
+
repo: str | os.PathLike[str] | None = None,
|
|
121
|
+
) -> int:
|
|
122
|
+
"""Remove each one, in order. Returns how many failed.
|
|
123
|
+
|
|
124
|
+
Serial because `worktree remove` and `branch -d` take the repository's
|
|
125
|
+
shared refs and its worktrees/ directory. One that git refuses is not a
|
|
126
|
+
reason to abandon the rest.
|
|
127
|
+
"""
|
|
128
|
+
main = R.main_worktree(repo)
|
|
129
|
+
failed = 0
|
|
130
|
+
for v in go:
|
|
131
|
+
try:
|
|
132
|
+
worktree_remove(v.path, repo=repo)
|
|
133
|
+
except (GitError, Refused) as exc:
|
|
134
|
+
say(f"{v.label} kept: {exc}")
|
|
135
|
+
failed += 1
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
_prune_empty_parents(Path(v.path).parent, main)
|
|
139
|
+
|
|
140
|
+
if v.branch:
|
|
141
|
+
try:
|
|
142
|
+
# -d, so git's own proof of merge decides. A squash-merged
|
|
143
|
+
# branch is refused here and kept: the checkout goes, the
|
|
144
|
+
# branch stays, and the restore line is not needed.
|
|
145
|
+
branch_delete(v.branch, repo=repo)
|
|
146
|
+
except (GitError, Refused) as exc:
|
|
147
|
+
say(f"branch {v.branch} kept: {exc}")
|
|
148
|
+
|
|
149
|
+
worktree_prune(repo=repo)
|
|
150
|
+
return failed
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _prune_empty_parents(start: Path, main: str) -> None:
|
|
154
|
+
"""Remove the directories that removing a worktree left empty.
|
|
155
|
+
|
|
156
|
+
Up to the worktrees root and never past it. rmdir refuses a directory
|
|
157
|
+
holding anything, which is the whole guard.
|
|
158
|
+
"""
|
|
159
|
+
if not main:
|
|
160
|
+
return
|
|
161
|
+
root = Path(main).parent / ".worktrees"
|
|
162
|
+
d = start
|
|
163
|
+
while d == root or root in d.parents:
|
|
164
|
+
try:
|
|
165
|
+
d.rmdir()
|
|
166
|
+
except OSError:
|
|
167
|
+
return
|
|
168
|
+
if d == root:
|
|
169
|
+
return
|
|
170
|
+
d = d.parent
|
worktrees/repo.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""What this repository is: its worktrees, its remote, its head branch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .git import git
|
|
10
|
+
|
|
11
|
+
# --------------------------------------------------------------------------
|
|
12
|
+
# the git calls
|
|
13
|
+
# --------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@git("worktree list --porcelain -z")
|
|
17
|
+
def worktree_list() -> None:
|
|
18
|
+
"""Every worktree, NUL-delimited."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@git("remote")
|
|
22
|
+
def remotes() -> None:
|
|
23
|
+
"""The names of every remote."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@git("config --get $key", ok=(0, 1))
|
|
27
|
+
def config_get(key: str) -> None:
|
|
28
|
+
"""One config value. Exit 1 means unset, not broken."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@git("symbolic-ref --quiet $ref", ok=(0, 1, 128))
|
|
32
|
+
def symbolic_ref(ref: str) -> None:
|
|
33
|
+
"""What a symbolic ref points at."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@git("symbolic-ref --quiet --short HEAD", ok=(0, 1, 128))
|
|
37
|
+
def current_branch() -> None:
|
|
38
|
+
"""The checked-out branch, short. Exit 1 on a detached HEAD."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@git("show-ref --verify --quiet $ref", ok=(0, 1))
|
|
42
|
+
def ref_exists(ref: str) -> None:
|
|
43
|
+
"""Does this exact ref exist?"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@git("for-each-ref --count=1 --format=%(refname) $prefix")
|
|
47
|
+
def first_ref_under(prefix: str) -> None:
|
|
48
|
+
"""One ref under a prefix, or nothing."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@git("for-each-ref --count=1 --contains $sha")
|
|
52
|
+
def refs_containing(sha: str) -> None:
|
|
53
|
+
"""One ref reaching this commit, or nothing."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# @{upstream} survives the spec: braces are git's revision syntax, not a
|
|
57
|
+
# placeholder, which is why placeholders are spelled with $.
|
|
58
|
+
@git("rev-parse --abbrev-ref --symbolic-full-name $branch@{upstream}", ok=(0, 1, 128))
|
|
59
|
+
def upstream_of(branch: str) -> None:
|
|
60
|
+
"""The upstream a branch tracks. Failure means unknown, not none."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@git("rev-list --count $a..$b", ok=(0, 128))
|
|
64
|
+
def count_between(a: str, b: str) -> None:
|
|
65
|
+
"""How many commits b has that a does not."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@git("rev-parse --show-toplevel", ok=(0, 128))
|
|
69
|
+
def toplevel() -> None:
|
|
70
|
+
"""The root of the worktree we stand in."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@git("rev-parse --path-format=absolute --git-common-dir", ok=(0, 128))
|
|
74
|
+
def common_dir() -> None:
|
|
75
|
+
"""The repository directory every worktree of it shares."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# --no-optional-locks is a git global, so it goes before the subcommand, which
|
|
79
|
+
# a spec shows and a tuple hides. Without it a listing writes another
|
|
80
|
+
# worktree's index and contends with a `git add` there.
|
|
81
|
+
@git("--no-optional-locks status --porcelain")
|
|
82
|
+
def status_porcelain() -> None:
|
|
83
|
+
"""Tracked and untracked changes, one line each."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# --ignored=traditional collapses an ignored directory into one entry, so
|
|
87
|
+
# node_modules/ is one line rather than forty thousand.
|
|
88
|
+
@git("--no-optional-locks status --porcelain --ignored=traditional")
|
|
89
|
+
def status_with_ignored() -> None:
|
|
90
|
+
"""The same, plus the gitignored paths git otherwise never mentions."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@git("fetch --prune $remote", mutates=True)
|
|
94
|
+
def fetch(remote: str) -> None:
|
|
95
|
+
"""Refresh every remote-tracking ref, dropping the ones that are gone."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@git("remote set-head $remote --auto", mutates=True)
|
|
99
|
+
def set_head_auto(remote: str) -> None:
|
|
100
|
+
"""Ask the server which branch it serves by default."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# --------------------------------------------------------------------------
|
|
104
|
+
# worktree records
|
|
105
|
+
# --------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class Worktree:
|
|
110
|
+
path: str
|
|
111
|
+
sha: str
|
|
112
|
+
branch: str # empty when detached or bare
|
|
113
|
+
flags: frozenset[str] # any of bare, detached, locked, prunable
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def label(self) -> str:
|
|
117
|
+
return self.branch or "(detached)"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def worktrees(repo: str | os.PathLike[str] | None = None) -> list[Worktree]:
|
|
121
|
+
"""Every worktree of this repository, main checkout first.
|
|
122
|
+
|
|
123
|
+
`git worktree list --porcelain -z` NUL-terminates each attribute and ends
|
|
124
|
+
a record with an empty one. Without -z git separates attributes by
|
|
125
|
+
newline, so a directory name holding one parses into two worktrees,
|
|
126
|
+
neither of which exists.
|
|
127
|
+
"""
|
|
128
|
+
out = worktree_list(repo=repo).out
|
|
129
|
+
found: list[Worktree] = []
|
|
130
|
+
seen: set[str] = set()
|
|
131
|
+
path = sha = branch = ""
|
|
132
|
+
flags: set[str] = set()
|
|
133
|
+
|
|
134
|
+
def emit() -> None:
|
|
135
|
+
nonlocal path, sha, branch, flags
|
|
136
|
+
if path:
|
|
137
|
+
# The same directory under two spellings is still one worktree.
|
|
138
|
+
key = str(Path(path).resolve())
|
|
139
|
+
if key not in seen:
|
|
140
|
+
seen.add(key)
|
|
141
|
+
found.append(Worktree(path, sha, branch, frozenset(flags)))
|
|
142
|
+
path = sha = branch = ""
|
|
143
|
+
flags = set()
|
|
144
|
+
|
|
145
|
+
for attr in out.split("\0"):
|
|
146
|
+
if attr.startswith("worktree "):
|
|
147
|
+
emit()
|
|
148
|
+
path = attr[len("worktree ") :]
|
|
149
|
+
elif attr.startswith("HEAD "):
|
|
150
|
+
sha = attr[len("HEAD ") :]
|
|
151
|
+
elif attr.startswith("branch "):
|
|
152
|
+
branch = attr[len("branch ") :].removeprefix("refs/heads/")
|
|
153
|
+
elif attr in ("bare", "detached"):
|
|
154
|
+
flags.add(attr)
|
|
155
|
+
elif attr.startswith("locked"):
|
|
156
|
+
flags.add("locked")
|
|
157
|
+
elif attr.startswith("prunable"):
|
|
158
|
+
flags.add("prunable")
|
|
159
|
+
emit()
|
|
160
|
+
return found
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def main_worktree(repo: str | os.PathLike[str] | None = None) -> str:
|
|
164
|
+
"""The original checkout. git lists it first."""
|
|
165
|
+
records = worktrees(repo)
|
|
166
|
+
return records[0].path if records else ""
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# --------------------------------------------------------------------------
|
|
170
|
+
# refs
|
|
171
|
+
# --------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def full_ref(name: str, repo: str | os.PathLike[str] | None = None) -> str:
|
|
175
|
+
"""The full ref naming branch `name`.
|
|
176
|
+
|
|
177
|
+
A bare name used as a revision is resolved as a tag before a branch, so a
|
|
178
|
+
tag called `origin/main` is what `origin/main` means to merge-base, cherry
|
|
179
|
+
and ^{tree}. Point that tag at a commit containing an unmerged branch and
|
|
180
|
+
the branch reads as merged: deleted, at exit 0, with nothing to say it
|
|
181
|
+
happened.
|
|
182
|
+
|
|
183
|
+
Remote-tracking first, because that is what a head branch normally is. A
|
|
184
|
+
name with no remote-tracking ref is spelled under refs/heads/ whether or
|
|
185
|
+
not it exists: a name resolving to nothing beats one resolving to a tag.
|
|
186
|
+
"""
|
|
187
|
+
if not name:
|
|
188
|
+
return ""
|
|
189
|
+
if ref_exists(f"refs/remotes/{name}", repo=repo):
|
|
190
|
+
return f"refs/remotes/{name}"
|
|
191
|
+
return f"refs/heads/{name}"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def ref_name(ref: str) -> str:
|
|
195
|
+
"""The ref as a person says it."""
|
|
196
|
+
return ref.removeprefix("refs/remotes/").removeprefix("refs/heads/")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def remote(repo: str | os.PathLike[str] | None = None) -> str:
|
|
200
|
+
"""The remote this repository belongs to, or empty when it has none.
|
|
201
|
+
|
|
202
|
+
`origin` is a convention, not a fact. remote.pushDefault is deliberately
|
|
203
|
+
not a rung: it names where commits go, not where they come from, and the
|
|
204
|
+
two differ in exactly the case that makes the question worth asking, a
|
|
205
|
+
fork you push to and an upstream you branch from.
|
|
206
|
+
"""
|
|
207
|
+
names = remotes(repo=repo).lines
|
|
208
|
+
if not names:
|
|
209
|
+
return ""
|
|
210
|
+
if len(names) == 1:
|
|
211
|
+
return names[0]
|
|
212
|
+
|
|
213
|
+
stated = [config_get("checkout.defaultRemote", repo=repo).out.strip()]
|
|
214
|
+
branch = current_branch(repo=repo).out.strip()
|
|
215
|
+
if branch:
|
|
216
|
+
stated.append(config_get(f"branch.{branch}.remote", repo=repo).out.strip())
|
|
217
|
+
|
|
218
|
+
for candidate in stated:
|
|
219
|
+
if candidate and candidate != "." and candidate in names:
|
|
220
|
+
return candidate
|
|
221
|
+
return "origin" if "origin" in names else names[0]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def head_ref(
|
|
225
|
+
remote_name: str,
|
|
226
|
+
online: bool,
|
|
227
|
+
repo: str | os.PathLike[str] | None = None,
|
|
228
|
+
) -> tuple[str, str]:
|
|
229
|
+
"""The full ref this repository branches from, and a warning or ''.
|
|
230
|
+
|
|
231
|
+
Returns a full ref, never `origin/main`: every merge question below uses
|
|
232
|
+
it as a revision, and a bare name resolves to a tag first.
|
|
233
|
+
"""
|
|
234
|
+
if remote_name:
|
|
235
|
+
# 1. The repository's own answer, recorded at clone time from what the
|
|
236
|
+
# server advertises, so it tells master from main without guessing.
|
|
237
|
+
# Ignored when it dangles, which is what a server-side rename
|
|
238
|
+
# leaves behind.
|
|
239
|
+
symref = symbolic_ref(f"refs/remotes/{remote_name}/HEAD", repo=repo).out.strip()
|
|
240
|
+
if symref and ref_exists(symref, repo=repo):
|
|
241
|
+
return symref, ""
|
|
242
|
+
|
|
243
|
+
# set-head can only point at a remote-tracking ref, so don't spend a
|
|
244
|
+
# round trip when the repository has none.
|
|
245
|
+
tracking = first_ref_under(f"refs/remotes/{remote_name}", repo=repo).out.strip()
|
|
246
|
+
if online and tracking and set_head_auto(remote_name, repo=repo):
|
|
247
|
+
symref = symbolic_ref(
|
|
248
|
+
f"refs/remotes/{remote_name}/HEAD", repo=repo
|
|
249
|
+
).out.strip()
|
|
250
|
+
if symref and ref_exists(symref, repo=repo):
|
|
251
|
+
return symref, ""
|
|
252
|
+
|
|
253
|
+
# 2. Conventional names, decisive only when exactly one exists.
|
|
254
|
+
# Remote-tracking first: a repository can have a remote and no
|
|
255
|
+
# refs/remotes at all, because a remote added by hand was never fetched.
|
|
256
|
+
found: list[str] = []
|
|
257
|
+
if remote_name:
|
|
258
|
+
found = [
|
|
259
|
+
f"{remote_name}/{c}"
|
|
260
|
+
for c in ("main", "master", "trunk")
|
|
261
|
+
if ref_exists(f"refs/remotes/{remote_name}/{c}", repo=repo)
|
|
262
|
+
]
|
|
263
|
+
if not found:
|
|
264
|
+
found = [
|
|
265
|
+
c
|
|
266
|
+
for c in ("main", "master", "trunk")
|
|
267
|
+
if ref_exists(f"refs/heads/{c}", repo=repo)
|
|
268
|
+
]
|
|
269
|
+
if not found:
|
|
270
|
+
return "", ""
|
|
271
|
+
|
|
272
|
+
# 3. Guess, and be honest that it is one.
|
|
273
|
+
warning = ""
|
|
274
|
+
if len(found) > 1:
|
|
275
|
+
warning = (
|
|
276
|
+
f"{remote_name}/HEAD is unset and " if remote_name else ""
|
|
277
|
+
) + f"{', '.join(found)} all exist, guessing {found[0]}"
|
|
278
|
+
if remote_name:
|
|
279
|
+
warning += f"; settle it with: git remote set-head {remote_name} --auto"
|
|
280
|
+
return full_ref(found[0], repo=repo), warning
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# --------------------------------------------------------------------------
|
|
284
|
+
# per-worktree questions
|
|
285
|
+
# --------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def is_dirty(path: str) -> bool:
|
|
289
|
+
"""Uncommitted work, which is what makes `git worktree remove` refuse."""
|
|
290
|
+
return bool(status_porcelain(repo=path).out.strip())
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def ignored_paths(path: str) -> list[str]:
|
|
294
|
+
"""The gitignored paths inside a worktree.
|
|
295
|
+
|
|
296
|
+
`git status --porcelain` does not mention these, so a checkout holding
|
|
297
|
+
.env and node_modules/ reads clean, and `git worktree remove` deletes both
|
|
298
|
+
at exit 0 without --force and without a word. Nothing in git brings them
|
|
299
|
+
back: no ref ever pointed at them.
|
|
300
|
+
"""
|
|
301
|
+
return [
|
|
302
|
+
line[3:]
|
|
303
|
+
for line in status_with_ignored(repo=path).lines
|
|
304
|
+
if line.startswith("!! ")
|
|
305
|
+
]
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def unpushed_count(
|
|
309
|
+
branch: str, repo: str | os.PathLike[str] | None = None
|
|
310
|
+
) -> int | None:
|
|
311
|
+
"""Commits the upstream has not got, or None when there is no upstream.
|
|
312
|
+
|
|
313
|
+
None is not zero. Branches made here do not track, so "no upstream" is the
|
|
314
|
+
normal state, and reporting zero would quietly disarm the guard for
|
|
315
|
+
exactly those.
|
|
316
|
+
"""
|
|
317
|
+
up = upstream_of(branch, repo=repo)
|
|
318
|
+
if not up or not up.out.strip():
|
|
319
|
+
return None
|
|
320
|
+
counted = count_between(up.out.strip(), f"refs/heads/{branch}", repo=repo)
|
|
321
|
+
if not counted:
|
|
322
|
+
return None
|
|
323
|
+
return int(counted.out.strip() or 0)
|