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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: git-nearest
3
- Version: 0.1.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.1.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" }
@@ -1,3 +1,3 @@
1
1
  """Find the remote branches closest to HEAD."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
@@ -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 head_line(note, paint):
25
- """The line above the table: where HEAD is and whether the working tree is clean."""
26
- if note is None:
27
- return None
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
- label = "HEAD -> " + branch if branch else "HEAD (detached)"
30
- return "%s %s %s" % (paint(color.GREEN_BOLD, "@"), paint(color.GREEN_BOLD, label), note)
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
- rows = refs.rank(args.limit)
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
- note = None if args.no_status else status.describe(status.read(), paint)
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 = git("rev-parse", "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, "HEAD -> " + branch if branch else "HEAD (detached)"))
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.YELLOW, "(%d)" % folded[c]))
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:HEAD)%09%(refname)", REMOTES, check=False)
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 + "...HEAD"), 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 HEAD is ahead by, then fewest it is behind by."""
31
- pairs = (_counts_native() if native else None) or _counts_fallback()
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 HEAD = ahead>"
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, "HEAD", check=False)[:8]
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