git-nearest 0.1.0__tar.gz → 0.2.0__tar.gz
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_nearest-0.1.0 → git_nearest-0.2.0}/PKG-INFO +7 -5
- {git_nearest-0.1.0 → git_nearest-0.2.0}/README.md +6 -4
- {git_nearest-0.1.0 → git_nearest-0.2.0}/pyproject.toml +1 -1
- {git_nearest-0.1.0 → git_nearest-0.2.0}/src/git_nearest/__init__.py +1 -1
- {git_nearest-0.1.0 → git_nearest-0.2.0}/src/git_nearest/cli.py +24 -11
- {git_nearest-0.1.0 → git_nearest-0.2.0}/src/git_nearest/graph.py +11 -6
- {git_nearest-0.1.0 → git_nearest-0.2.0}/src/git_nearest/refs.py +12 -9
- {git_nearest-0.1.0 → git_nearest-0.2.0}/src/git_nearest/table.py +2 -2
- {git_nearest-0.1.0 → git_nearest-0.2.0}/tests/test_cli.py +27 -0
- {git_nearest-0.1.0 → git_nearest-0.2.0}/tests/test_graph.py +18 -0
- {git_nearest-0.1.0 → git_nearest-0.2.0}/.gitignore +0 -0
- {git_nearest-0.1.0 → git_nearest-0.2.0}/LICENSE +0 -0
- {git_nearest-0.1.0 → git_nearest-0.2.0}/src/git_nearest/__main__.py +0 -0
- {git_nearest-0.1.0 → git_nearest-0.2.0}/src/git_nearest/color.py +0 -0
- {git_nearest-0.1.0 → git_nearest-0.2.0}/src/git_nearest/gitutil.py +0 -0
- {git_nearest-0.1.0 → git_nearest-0.2.0}/src/git_nearest/status.py +0 -0
- {git_nearest-0.1.0 → git_nearest-0.2.0}/tests/conftest.py +0 -0
- {git_nearest-0.1.0 → git_nearest-0.2.0}/tests/test_status.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: git-nearest
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Find the remote branches closest to your current branch, with ahead/behind counts and a compact jj-style graph.
|
|
5
5
|
Project-URL: Homepage, https://github.com/hwei/git-nearest
|
|
6
6
|
Project-URL: Issues, https://github.com/hwei/git-nearest/issues
|
|
@@ -40,7 +40,7 @@ instead of having one picked for you.
|
|
|
40
40
|
## Graph
|
|
41
41
|
|
|
42
42
|
`git nearest -g` draws how HEAD and those branches relate, in the style of
|
|
43
|
-
[`jj log`](https://docs.jj-vcs.dev/): newest on top, and runs of ordinary commits folded into a
|
|
43
|
+
[`jj log`](https://docs.jj-vcs.dev/): newest on top (by commit date, parents never above their children), and runs of ordinary commits folded into a
|
|
44
44
|
`~ (n)` row on the vertical line.
|
|
45
45
|
|
|
46
46
|
```console
|
|
@@ -50,12 +50,12 @@ $ git nearest -g
|
|
|
50
50
|
○ 574e9395 origin/main
|
|
51
51
|
├─╮
|
|
52
52
|
~ │ (3)
|
|
53
|
-
│ ○ 51eef8f5 origin/feature/login
|
|
54
|
-
│ ~ (2)
|
|
55
53
|
│ │ ○ 2cf4c0c2 origin/release/1.2
|
|
56
54
|
├─┼─╯
|
|
57
55
|
○ │ fc1afa3b merge-base
|
|
58
56
|
~ │ (1)
|
|
57
|
+
│ ○ 51eef8f5 origin/feature/login
|
|
58
|
+
│ ~ (2)
|
|
59
59
|
├─╯
|
|
60
60
|
○ 5a42fea0 merge-base
|
|
61
61
|
~ (n) = n commits folded between
|
|
@@ -81,15 +81,17 @@ The package installs an executable called `git-nearest`; git runs any `git-<name
|
|
|
81
81
|
## Usage
|
|
82
82
|
|
|
83
83
|
```
|
|
84
|
-
git nearest [N] [-g] [--no-status] [--color {auto,always,never}] [--no-color]
|
|
84
|
+
git nearest [N] [-b REV] [-g] [--no-status] [--color {auto,always,never}] [--no-color] [--version]
|
|
85
85
|
```
|
|
86
86
|
|
|
87
87
|
| | |
|
|
88
88
|
|---|---|
|
|
89
89
|
| `N` | how many branches to show (default 5) |
|
|
90
|
+
| `-b`, `--branch REV` | measure from this branch or commit instead of HEAD; skips the working tree check, which only describes HEAD |
|
|
90
91
|
| `-g`, `--graph` | draw the graph instead of the table |
|
|
91
92
|
| `--no-status` | skip the working tree check (it runs `git status`, which can be slow on huge repositories) |
|
|
92
93
|
| `--color` | `auto` (default) colors only on a terminal and respects `NO_COLOR` |
|
|
94
|
+
| `--version` | print the version and exit |
|
|
93
95
|
|
|
94
96
|
## How it works
|
|
95
97
|
|
|
@@ -22,7 +22,7 @@ instead of having one picked for you.
|
|
|
22
22
|
## Graph
|
|
23
23
|
|
|
24
24
|
`git nearest -g` draws how HEAD and those branches relate, in the style of
|
|
25
|
-
[`jj log`](https://docs.jj-vcs.dev/): newest on top, and runs of ordinary commits folded into a
|
|
25
|
+
[`jj log`](https://docs.jj-vcs.dev/): newest on top (by commit date, parents never above their children), and runs of ordinary commits folded into a
|
|
26
26
|
`~ (n)` row on the vertical line.
|
|
27
27
|
|
|
28
28
|
```console
|
|
@@ -32,12 +32,12 @@ $ git nearest -g
|
|
|
32
32
|
○ 574e9395 origin/main
|
|
33
33
|
├─╮
|
|
34
34
|
~ │ (3)
|
|
35
|
-
│ ○ 51eef8f5 origin/feature/login
|
|
36
|
-
│ ~ (2)
|
|
37
35
|
│ │ ○ 2cf4c0c2 origin/release/1.2
|
|
38
36
|
├─┼─╯
|
|
39
37
|
○ │ fc1afa3b merge-base
|
|
40
38
|
~ │ (1)
|
|
39
|
+
│ ○ 51eef8f5 origin/feature/login
|
|
40
|
+
│ ~ (2)
|
|
41
41
|
├─╯
|
|
42
42
|
○ 5a42fea0 merge-base
|
|
43
43
|
~ (n) = n commits folded between
|
|
@@ -63,15 +63,17 @@ The package installs an executable called `git-nearest`; git runs any `git-<name
|
|
|
63
63
|
## Usage
|
|
64
64
|
|
|
65
65
|
```
|
|
66
|
-
git nearest [N] [-g] [--no-status] [--color {auto,always,never}] [--no-color]
|
|
66
|
+
git nearest [N] [-b REV] [-g] [--no-status] [--color {auto,always,never}] [--no-color] [--version]
|
|
67
67
|
```
|
|
68
68
|
|
|
69
69
|
| | |
|
|
70
70
|
|---|---|
|
|
71
71
|
| `N` | how many branches to show (default 5) |
|
|
72
|
+
| `-b`, `--branch REV` | measure from this branch or commit instead of HEAD; skips the working tree check, which only describes HEAD |
|
|
72
73
|
| `-g`, `--graph` | draw the graph instead of the table |
|
|
73
74
|
| `--no-status` | skip the working tree check (it runs `git status`, which can be slow on huge repositories) |
|
|
74
75
|
| `--color` | `auto` (default) colors only on a terminal and respects `NO_COLOR` |
|
|
76
|
+
| `--version` | print the version and exit |
|
|
75
77
|
|
|
76
78
|
## How it works
|
|
77
79
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "git-nearest"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Find the remote branches closest to your current branch, with ahead/behind counts and a compact jj-style graph."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -13,6 +13,8 @@ def _parser():
|
|
|
13
13
|
description="List the remote branches closest to HEAD (fewest commits ahead, then fewest behind).",
|
|
14
14
|
)
|
|
15
15
|
p.add_argument("limit", nargs="?", type=int, default=5, help="how many refs to show (default: 5)")
|
|
16
|
+
p.add_argument("-b", "--branch", metavar="REV", default="HEAD",
|
|
17
|
+
help="measure from this branch or commit instead of HEAD (skips the working tree check)")
|
|
16
18
|
p.add_argument("-g", "--graph", action="store_true", help="draw how HEAD and those refs relate")
|
|
17
19
|
p.add_argument("--color", choices=["auto", "always", "never"], default="auto")
|
|
18
20
|
p.add_argument("--no-color", dest="color", action="store_const", const="never", help="same as --color=never")
|
|
@@ -21,13 +23,18 @@ def _parser():
|
|
|
21
23
|
return p
|
|
22
24
|
|
|
23
25
|
|
|
24
|
-
def
|
|
25
|
-
"""
|
|
26
|
-
if
|
|
27
|
-
return
|
|
26
|
+
def head_label(rev):
|
|
27
|
+
"""How to name the commit we measure from: "HEAD -> main", "HEAD (detached)", or REV as typed."""
|
|
28
|
+
if rev != "HEAD":
|
|
29
|
+
return rev
|
|
28
30
|
branch = git("symbolic-ref", "--short", "-q", "HEAD", check=False)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
return "HEAD -> " + branch if branch else "HEAD (detached)"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def head_line(label, note, paint):
|
|
35
|
+
"""The line above the table: what we measure from and whether the working tree is clean."""
|
|
36
|
+
line = "%s %s" % (paint(color.GREEN_BOLD, "@"), paint(color.GREEN_BOLD, label))
|
|
37
|
+
return line + " " + note if note else line
|
|
31
38
|
|
|
32
39
|
|
|
33
40
|
def _use_color(mode):
|
|
@@ -46,17 +53,23 @@ def main(argv=None):
|
|
|
46
53
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
47
54
|
|
|
48
55
|
try:
|
|
49
|
-
git("rev-parse", "--verify", "-q", "HEAD")
|
|
50
|
-
|
|
56
|
+
base = git("rev-parse", "--verify", "-q", args.branch + "^{commit}", check=args.branch == "HEAD")
|
|
57
|
+
if not base:
|
|
58
|
+
raise GitError("not a valid branch or commit: " + args.branch)
|
|
59
|
+
skip = git("rev-parse", "--symbolic-full-name", args.branch, check=False)
|
|
60
|
+
rows = refs.rank(args.limit, base, skip)
|
|
51
61
|
if not rows:
|
|
52
62
|
print("git-nearest: no remote branches found (try `git fetch`)", file=sys.stderr)
|
|
53
63
|
return 1
|
|
54
64
|
paint = Painter(_use_color(args.color))
|
|
55
|
-
|
|
65
|
+
# the working tree belongs to HEAD, so it says nothing about another branch
|
|
66
|
+
checks_tree = args.branch == "HEAD" and not args.no_status
|
|
67
|
+
note = status.describe(status.read(), paint) if checks_tree else None
|
|
68
|
+
label = head_label(args.branch)
|
|
56
69
|
if args.graph:
|
|
57
|
-
lines = graph.render([r.ref for r in rows], paint, note)
|
|
70
|
+
lines = graph.render([r.ref for r in rows], paint, base, label, note)
|
|
58
71
|
else:
|
|
59
|
-
lines = table.render(rows, paint, head_line(note, paint))
|
|
72
|
+
lines = table.render(rows, paint, base, head_line(label, note, paint) if note or args.branch != "HEAD" else None)
|
|
60
73
|
except GitError as e:
|
|
61
74
|
print("git-nearest: " + str(e), file=sys.stderr)
|
|
62
75
|
return 1
|
|
@@ -10,9 +10,8 @@ LINE_GLYPHS = set("│├┴┼╯╮┤─~") # │ ├ ┴ ┼ ╯ ╮ ┤
|
|
|
10
10
|
LEGEND = "~ (n) = n commits folded between"
|
|
11
11
|
|
|
12
12
|
|
|
13
|
-
def render(refs, paint, head_note=None):
|
|
14
|
-
head
|
|
15
|
-
branch = git("symbolic-ref", "--short", "-q", "HEAD", check=False)
|
|
13
|
+
def render(refs, paint, head, label, head_note=None):
|
|
14
|
+
"""head: the commit everything is measured from; label: how to name it (e.g. "HEAD -> main")."""
|
|
16
15
|
names = {} # commit -> remote refs pointing at it
|
|
17
16
|
shas = git("rev-parse", *["refs/remotes/" + r for r in refs]).split()
|
|
18
17
|
for ref, sha in zip(refs, shas):
|
|
@@ -56,8 +55,14 @@ def render(refs, paint, head_note=None):
|
|
|
56
55
|
def on_head_line(c):
|
|
57
56
|
return c == head or bool(allanc[head] & node_bit[c]) or bool(allanc[c] & node_bit[head])
|
|
58
57
|
|
|
58
|
+
# Newest first among the nodes that are ready to draw; children still always come before parents.
|
|
59
|
+
when = {}
|
|
60
|
+
for line in git("show", "-s", "--format=%H %ct", *nodes).splitlines():
|
|
61
|
+
sha, ts = line.split()
|
|
62
|
+
when[sha] = int(ts)
|
|
63
|
+
|
|
59
64
|
def sort_key(c):
|
|
60
|
-
return (not on_head_line(c), sorted(names.get(c, [c]))[0])
|
|
65
|
+
return (-when[c], not on_head_line(c), sorted(names.get(c, [c]))[0])
|
|
61
66
|
|
|
62
67
|
above = {} # node -> nearest ancestor nodes, head line first
|
|
63
68
|
folded = {} # node -> commits between it and those ancestors
|
|
@@ -75,7 +80,7 @@ def render(refs, paint, head_note=None):
|
|
|
75
80
|
def node_text(c):
|
|
76
81
|
parts = []
|
|
77
82
|
if c == head:
|
|
78
|
-
parts.append(paint(color.GREEN_BOLD,
|
|
83
|
+
parts.append(paint(color.GREEN_BOLD, label))
|
|
79
84
|
parts += [paint(color.CYAN, n) for n in sorted(names.get(c, []))]
|
|
80
85
|
if not parts:
|
|
81
86
|
parts.append(paint(color.DIM, "merge-base"))
|
|
@@ -136,7 +141,7 @@ def render(refs, paint, head_note=None):
|
|
|
136
141
|
cells[i] = "┼" if lanes[i] else "─"
|
|
137
142
|
emit(cells, fill=range(lo, hi))
|
|
138
143
|
if folded.get(c):
|
|
139
|
-
emit({k: "~"}, text=paint(color.
|
|
144
|
+
emit({k: "~"}, text=paint(color.DIM, "(%d)" % folded[c]))
|
|
140
145
|
|
|
141
146
|
pending = {c: 0 for c in nodes}
|
|
142
147
|
for c in nodes:
|
|
@@ -8,32 +8,35 @@ REMOTES = "refs/remotes/"
|
|
|
8
8
|
Row = namedtuple("Row", "ahead behind ref") # ref is the short name, e.g. origin/main
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
def _counts_native():
|
|
11
|
+
def _counts_native(base):
|
|
12
12
|
"""One git call for every ref; needs git >= 2.41 (%(ahead-behind:...))."""
|
|
13
|
-
out = git("for-each-ref", "--format=%(ahead-behind:
|
|
13
|
+
out = git("for-each-ref", "--format=%(ahead-behind:" + base + ")%09%(refname)", REMOTES, check=False)
|
|
14
14
|
if not out:
|
|
15
15
|
return None
|
|
16
16
|
return [line.split("\t") for line in out.splitlines()]
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
def _counts_fallback():
|
|
19
|
+
def _counts_fallback(base):
|
|
20
20
|
"""One rev-list per ref, for older git."""
|
|
21
21
|
names = git("for-each-ref", "--format=%(refname)", REMOTES).splitlines()
|
|
22
22
|
pairs = []
|
|
23
23
|
for name in names:
|
|
24
24
|
if not name.endswith("/HEAD"):
|
|
25
|
-
pairs.append((git("rev-list", "--left-right", "--count", name + "...
|
|
25
|
+
pairs.append((git("rev-list", "--left-right", "--count", name + "..." + base), name))
|
|
26
26
|
return pairs
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
def rank(limit, native=True):
|
|
30
|
-
"""Closest remote refs first: fewest commits
|
|
31
|
-
|
|
29
|
+
def rank(limit, base="HEAD", skip=None, native=True):
|
|
30
|
+
"""Closest remote refs first: fewest commits base is ahead by, then fewest it is behind by.
|
|
31
|
+
|
|
32
|
+
skip is a full refname to leave out (the ref we are measuring from).
|
|
33
|
+
"""
|
|
34
|
+
pairs = (_counts_native(base) if native else None) or _counts_fallback(base)
|
|
32
35
|
rows = []
|
|
33
36
|
for counts, name in pairs:
|
|
34
|
-
if name.endswith("/HEAD"): # origin/HEAD is just a pointer
|
|
37
|
+
if name == skip or name.endswith("/HEAD"): # origin/HEAD is just a pointer
|
|
35
38
|
continue
|
|
36
|
-
# "<only in ref = behind> <only in
|
|
39
|
+
# "<only in ref = behind> <only in base = ahead>"
|
|
37
40
|
behind, ahead = counts.split()
|
|
38
41
|
rows.append(Row(int(ahead), int(behind), name[len(REMOTES):]))
|
|
39
42
|
rows.sort(key=lambda r: (r.ahead, r.behind, r.ref))
|
|
@@ -3,13 +3,13 @@ from . import color
|
|
|
3
3
|
from .gitutil import git
|
|
4
4
|
|
|
5
5
|
|
|
6
|
-
def render(rows, paint, head_line=None):
|
|
6
|
+
def render(rows, paint, base, head_line=None):
|
|
7
7
|
ups = ["↑%d" % r.ahead for r in rows]
|
|
8
8
|
downs = ["↓%d" % r.behind for r in rows]
|
|
9
9
|
wa, wb, wn = max(map(len, ups)), max(map(len, downs)), max(len(r.ref) for r in rows)
|
|
10
10
|
lines = [head_line] if head_line else []
|
|
11
11
|
for r, up, down in zip(rows, ups, downs):
|
|
12
|
-
base = git("merge-base", "refs/remotes/" + r.ref,
|
|
12
|
+
base = git("merge-base", "refs/remotes/" + r.ref, base, check=False)[:8]
|
|
13
13
|
lines.append("%s %s %s %s" % (
|
|
14
14
|
paint(color.GREEN, up.ljust(wa)),
|
|
15
15
|
paint(color.RED, down.ljust(wb)),
|
|
@@ -79,3 +79,30 @@ def test_bad_limit(forked):
|
|
|
79
79
|
def test_color_always_emits_escapes(forked, capsys):
|
|
80
80
|
main(["--color", "always"])
|
|
81
81
|
assert "\033[" in capsys.readouterr().out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_branch_measures_from_another_rev(forked, capsys):
|
|
85
|
+
_, out, _ = run(capsys, "-b", "main")
|
|
86
|
+
assert out.splitlines() == [
|
|
87
|
+
"@ main",
|
|
88
|
+
"↑1 ↓0 origin/other H",
|
|
89
|
+
"↑5 ↓0 origin/feat H",
|
|
90
|
+
"↑6 ↓0 origin/main H",
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_branch_skips_itself_when_remote(forked, capsys):
|
|
95
|
+
_, out, _ = run(capsys, "-b", "origin/other")
|
|
96
|
+
assert "origin/other" not in out.splitlines()[1]
|
|
97
|
+
assert out.splitlines()[0] == "@ origin/other"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_branch_graph_is_labelled(forked, capsys):
|
|
101
|
+
_, out, _ = run(capsys, "-g", "-b", "feat")
|
|
102
|
+
assert any(l.startswith("@") and l.endswith("H feat, origin/feat") for l in out.splitlines())
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_bad_branch(forked, capsys):
|
|
106
|
+
code, _, err = run(capsys, "-b", "nope")
|
|
107
|
+
assert code == 1
|
|
108
|
+
assert "nope" in err
|
|
@@ -70,3 +70,21 @@ def test_unrelated_histories_do_not_crash(repo, capsys):
|
|
|
70
70
|
lines = graph(capsys)
|
|
71
71
|
assert any("origin/main" in line for line in lines)
|
|
72
72
|
assert any("HEAD -> other" in line for line in lines)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_newest_first_among_branches(repo, capsys, monkeypatch):
|
|
76
|
+
def commit_at(ts):
|
|
77
|
+
monkeypatch.setenv("GIT_COMMITTER_DATE", "%d +0000" % ts)
|
|
78
|
+
repo.commit()
|
|
79
|
+
|
|
80
|
+
commit_at(1000000000)
|
|
81
|
+
repo.git("checkout", "-q", "-b", "side")
|
|
82
|
+
commit_at(1000000100)
|
|
83
|
+
repo.remote("origin/a-old") # sorts first by name but is older
|
|
84
|
+
repo.git("checkout", "-q", "main")
|
|
85
|
+
commit_at(1000000200)
|
|
86
|
+
repo.remote("origin/z-new")
|
|
87
|
+
commit_at(1000000300)
|
|
88
|
+
lines = graph(capsys)
|
|
89
|
+
refs = [l for l in lines if "origin/" in l]
|
|
90
|
+
assert [l.split()[-1] for l in refs] == ["origin/z-new", "origin/a-old"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|