git-nearest 0.1.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.
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ .pytest_cache/
6
+ uv.lock
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hwei
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.5
2
+ Name: git-nearest
3
+ Version: 0.1.0
4
+ Summary: Find the remote branches closest to your current branch, with ahead/behind counts and a compact jj-style graph.
5
+ Project-URL: Homepage, https://github.com/hwei/git-nearest
6
+ Project-URL: Issues, https://github.com/hwei/git-nearest/issues
7
+ Author-email: hwei <814065+hwei@users.noreply.github.com>
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: branch,cli,git,merge-base,parent-branch
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Topic :: Software Development :: Version Control :: Git
16
+ Requires-Python: >=3.8
17
+ Description-Content-Type: text/markdown
18
+
19
+ # git-nearest
20
+
21
+ Which remote branch is my current branch closest to?
22
+
23
+ ```console
24
+ $ git nearest
25
+ @ HEAD -> fix-typo [1 staged, 1 untracked]
26
+ ↑2 ↓0 origin/main 574e9395
27
+ ↑8 ↓0 origin/feature/login 51eef8f5
28
+ ↑9 ↓1 origin/release/1.2 fc1afa3b
29
+ ```
30
+
31
+ The first line shows where HEAD is and the state of the working tree: `[clean]`, or counts such as
32
+ `[2 staged, 3 modified, 1 untracked]` (conflicted files turn it red). That is the thing to check
33
+ before moving the branch somewhere else.
34
+
35
+ `↑` is how many commits HEAD has that the remote branch doesn't, `↓` is how many the remote branch
36
+ has that HEAD doesn't, and the last column is the merge-base. The closest branch (fewest `↑`, then
37
+ fewest `↓`) comes first. Remote branches with the same tip all show up, so you can see ties
38
+ instead of having one picked for you.
39
+
40
+ ## Graph
41
+
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
44
+ `~ (n)` row on the vertical line.
45
+
46
+ ```console
47
+ $ git nearest -g
48
+ @ ae22273d HEAD -> fix-typo [1 staged, 1 untracked]
49
+ ~ (1)
50
+ ○ 574e9395 origin/main
51
+ ├─╮
52
+ ~ │ (3)
53
+ │ ○ 51eef8f5 origin/feature/login
54
+ │ ~ (2)
55
+ │ │ ○ 2cf4c0c2 origin/release/1.2
56
+ ├─┼─╯
57
+ ○ │ fc1afa3b merge-base
58
+ ~ │ (1)
59
+ ├─╯
60
+ ○ 5a42fea0 merge-base
61
+ ~ (n) = n commits folded between
62
+ ```
63
+
64
+ Only HEAD, the branch tips and the merge-bases between them are drawn. `(3)` means three commits sit
65
+ between the nodes above and below it.
66
+
67
+ Both examples are real output from a synthetic repository built by
68
+ [`scripts/demo.py`](https://github.com/hwei/git-nearest/blob/main/scripts/demo.py).
69
+
70
+ ## Install
71
+
72
+ Needs Python 3.8+ and git. No other dependencies.
73
+
74
+ ```console
75
+ uv tool install git-nearest # or: pipx install git-nearest
76
+ ```
77
+
78
+ The package installs an executable called `git-nearest`; git runs any `git-<name>` on your `PATH` as
79
+ `git <name>`.
80
+
81
+ ## Usage
82
+
83
+ ```
84
+ git nearest [N] [-g] [--no-status] [--color {auto,always,never}] [--no-color]
85
+ ```
86
+
87
+ | | |
88
+ |---|---|
89
+ | `N` | how many branches to show (default 5) |
90
+ | `-g`, `--graph` | draw the graph instead of the table |
91
+ | `--no-status` | skip the working tree check (it runs `git status`, which can be slow on huge repositories) |
92
+ | `--color` | `auto` (default) colors only on a terminal and respects `NO_COLOR` |
93
+
94
+ ## How it works
95
+
96
+ git has no notion of a branch's "parent", only of common ancestors, so this is a heuristic, not a
97
+ lookup. For every ref under `refs/remotes` it asks git for the ahead/behind counts against HEAD in a
98
+ single `for-each-ref --format='%(ahead-behind:HEAD)'` call (git 2.41+; older versions fall back to one
99
+ `rev-list` per ref, which is slower on repositories with many branches).
100
+
101
+ The graph walks the history above the common base once, marks which tips can reach each commit, and
102
+ keeps only the commits where those sets change. Those are the merge-bases.
103
+
104
+ ## Limitations
105
+
106
+ - "Nearest" is by commit count. If you branched from `main` last week and `release/1.0` happens to be
107
+ two commits behind `main`, `release/1.0` is nearer. Look at the graph when it matters.
108
+ - Branches that are not in `refs/remotes` are ignored. Run `git fetch` first.
109
+ - Folded counts on a node with two parents are the commits unique to that node, shown once.
110
+
111
+ ## Similar tools
112
+
113
+ - Scripts based on `git show-branch` or on grepping `git log --decorate` for `origin/` (the usual
114
+ Stack Overflow answers) look only at the first decorated ancestor, so they miss ties and give no
115
+ distance.
116
+ - [Git Town](https://www.git-town.com/) records a parent for each branch when it creates the branch.
117
+ That is exact but only works for branches it knows about; `git-nearest` infers from history and needs
118
+ no setup.
119
+
120
+ ## Development
121
+
122
+ ```console
123
+ uv run pytest
124
+ ```
125
+
126
+ ## License
127
+
128
+ MIT
@@ -0,0 +1,110 @@
1
+ # git-nearest
2
+
3
+ Which remote branch is my current branch closest to?
4
+
5
+ ```console
6
+ $ git nearest
7
+ @ HEAD -> fix-typo [1 staged, 1 untracked]
8
+ ↑2 ↓0 origin/main 574e9395
9
+ ↑8 ↓0 origin/feature/login 51eef8f5
10
+ ↑9 ↓1 origin/release/1.2 fc1afa3b
11
+ ```
12
+
13
+ The first line shows where HEAD is and the state of the working tree: `[clean]`, or counts such as
14
+ `[2 staged, 3 modified, 1 untracked]` (conflicted files turn it red). That is the thing to check
15
+ before moving the branch somewhere else.
16
+
17
+ `↑` is how many commits HEAD has that the remote branch doesn't, `↓` is how many the remote branch
18
+ has that HEAD doesn't, and the last column is the merge-base. The closest branch (fewest `↑`, then
19
+ fewest `↓`) comes first. Remote branches with the same tip all show up, so you can see ties
20
+ instead of having one picked for you.
21
+
22
+ ## Graph
23
+
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
26
+ `~ (n)` row on the vertical line.
27
+
28
+ ```console
29
+ $ git nearest -g
30
+ @ ae22273d HEAD -> fix-typo [1 staged, 1 untracked]
31
+ ~ (1)
32
+ ○ 574e9395 origin/main
33
+ ├─╮
34
+ ~ │ (3)
35
+ │ ○ 51eef8f5 origin/feature/login
36
+ │ ~ (2)
37
+ │ │ ○ 2cf4c0c2 origin/release/1.2
38
+ ├─┼─╯
39
+ ○ │ fc1afa3b merge-base
40
+ ~ │ (1)
41
+ ├─╯
42
+ ○ 5a42fea0 merge-base
43
+ ~ (n) = n commits folded between
44
+ ```
45
+
46
+ Only HEAD, the branch tips and the merge-bases between them are drawn. `(3)` means three commits sit
47
+ between the nodes above and below it.
48
+
49
+ Both examples are real output from a synthetic repository built by
50
+ [`scripts/demo.py`](https://github.com/hwei/git-nearest/blob/main/scripts/demo.py).
51
+
52
+ ## Install
53
+
54
+ Needs Python 3.8+ and git. No other dependencies.
55
+
56
+ ```console
57
+ uv tool install git-nearest # or: pipx install git-nearest
58
+ ```
59
+
60
+ The package installs an executable called `git-nearest`; git runs any `git-<name>` on your `PATH` as
61
+ `git <name>`.
62
+
63
+ ## Usage
64
+
65
+ ```
66
+ git nearest [N] [-g] [--no-status] [--color {auto,always,never}] [--no-color]
67
+ ```
68
+
69
+ | | |
70
+ |---|---|
71
+ | `N` | how many branches to show (default 5) |
72
+ | `-g`, `--graph` | draw the graph instead of the table |
73
+ | `--no-status` | skip the working tree check (it runs `git status`, which can be slow on huge repositories) |
74
+ | `--color` | `auto` (default) colors only on a terminal and respects `NO_COLOR` |
75
+
76
+ ## How it works
77
+
78
+ git has no notion of a branch's "parent", only of common ancestors, so this is a heuristic, not a
79
+ lookup. For every ref under `refs/remotes` it asks git for the ahead/behind counts against HEAD in a
80
+ single `for-each-ref --format='%(ahead-behind:HEAD)'` call (git 2.41+; older versions fall back to one
81
+ `rev-list` per ref, which is slower on repositories with many branches).
82
+
83
+ The graph walks the history above the common base once, marks which tips can reach each commit, and
84
+ keeps only the commits where those sets change. Those are the merge-bases.
85
+
86
+ ## Limitations
87
+
88
+ - "Nearest" is by commit count. If you branched from `main` last week and `release/1.0` happens to be
89
+ two commits behind `main`, `release/1.0` is nearer. Look at the graph when it matters.
90
+ - Branches that are not in `refs/remotes` are ignored. Run `git fetch` first.
91
+ - Folded counts on a node with two parents are the commits unique to that node, shown once.
92
+
93
+ ## Similar tools
94
+
95
+ - Scripts based on `git show-branch` or on grepping `git log --decorate` for `origin/` (the usual
96
+ Stack Overflow answers) look only at the first decorated ancestor, so they miss ties and give no
97
+ distance.
98
+ - [Git Town](https://www.git-town.com/) records a parent for each branch when it creates the branch.
99
+ That is exact but only works for branches it knows about; `git-nearest` infers from history and needs
100
+ no setup.
101
+
102
+ ## Development
103
+
104
+ ```console
105
+ uv run pytest
106
+ ```
107
+
108
+ ## License
109
+
110
+ MIT
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "git-nearest"
7
+ version = "0.1.0"
8
+ description = "Find the remote branches closest to your current branch, with ahead/behind counts and a compact jj-style graph."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.8"
12
+ authors = [{ name = "hwei", email = "814065+hwei@users.noreply.github.com" }]
13
+ keywords = ["git", "branch", "cli", "merge-base", "parent-branch"]
14
+ classifiers = [
15
+ "Environment :: Console",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Topic :: Software Development :: Version Control :: Git",
20
+ ]
21
+ dependencies = []
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/hwei/git-nearest"
25
+ Issues = "https://github.com/hwei/git-nearest/issues"
26
+
27
+ # An executable named git-<name> on PATH is run by `git <name>`.
28
+ [project.scripts]
29
+ git-nearest = "git_nearest.cli:main"
30
+
31
+ [dependency-groups]
32
+ dev = ["pytest"]
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/git_nearest"]
36
+
37
+ # Allowlist: hatch ignores .git/info/exclude, so local-only files would otherwise ship.
38
+ [tool.hatch.build.targets.sdist]
39
+ include = ["src/git_nearest", "tests", "README.md", "LICENSE", "pyproject.toml"]
40
+
41
+ [tool.pytest.ini_options]
42
+ testpaths = ["tests"]
@@ -0,0 +1,3 @@
1
+ """Find the remote branches closest to HEAD."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,64 @@
1
+ import argparse
2
+ import os
3
+ import sys
4
+
5
+ from . import __version__, color, graph, refs, status, table
6
+ from .color import Painter
7
+ from .gitutil import GitError, git
8
+
9
+
10
+ def _parser():
11
+ p = argparse.ArgumentParser(
12
+ prog="git nearest",
13
+ description="List the remote branches closest to HEAD (fewest commits ahead, then fewest behind).",
14
+ )
15
+ p.add_argument("limit", nargs="?", type=int, default=5, help="how many refs to show (default: 5)")
16
+ p.add_argument("-g", "--graph", action="store_true", help="draw how HEAD and those refs relate")
17
+ p.add_argument("--color", choices=["auto", "always", "never"], default="auto")
18
+ p.add_argument("--no-color", dest="color", action="store_const", const="never", help="same as --color=never")
19
+ p.add_argument("--no-status", action="store_true", help="don't check the working tree (faster on huge repos)")
20
+ p.add_argument("--version", action="version", version="git-nearest " + __version__)
21
+ return p
22
+
23
+
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
28
+ 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
+
32
+
33
+ def _use_color(mode):
34
+ if mode == "always":
35
+ return True
36
+ return mode == "auto" and sys.stdout.isatty() and not os.environ.get("NO_COLOR")
37
+
38
+
39
+ def main(argv=None):
40
+ args = _parser().parse_args(argv)
41
+ if args.limit < 1:
42
+ sys.exit("git-nearest: limit must be at least 1")
43
+ if os.name == "nt":
44
+ os.system("") # turns on ANSI escape handling in the legacy Windows console
45
+ if not sys.stdout.isatty() and hasattr(sys.stdout, "reconfigure"):
46
+ sys.stdout.reconfigure(encoding="utf-8")
47
+
48
+ try:
49
+ git("rev-parse", "--verify", "-q", "HEAD")
50
+ rows = refs.rank(args.limit)
51
+ if not rows:
52
+ print("git-nearest: no remote branches found (try `git fetch`)", file=sys.stderr)
53
+ return 1
54
+ paint = Painter(_use_color(args.color))
55
+ note = None if args.no_status else status.describe(status.read(), paint)
56
+ if args.graph:
57
+ lines = graph.render([r.ref for r in rows], paint, note)
58
+ else:
59
+ lines = table.render(rows, paint, head_line(note, paint))
60
+ except GitError as e:
61
+ print("git-nearest: " + str(e), file=sys.stderr)
62
+ return 1
63
+ print("\n".join(lines))
64
+ return 0
@@ -0,0 +1,18 @@
1
+ """ANSI styling that can be switched off."""
2
+
3
+ DIM = "2"
4
+ GREEN = "32"
5
+ GREEN_BOLD = "1;32"
6
+ RED = "31"
7
+ YELLOW = "33"
8
+ CYAN = "36"
9
+
10
+
11
+ class Painter:
12
+ def __init__(self, enabled):
13
+ self.enabled = enabled
14
+
15
+ def __call__(self, code, text):
16
+ if not self.enabled or not text:
17
+ return text
18
+ return "\033[%sm%s\033[0m" % (code, text)
@@ -0,0 +1,24 @@
1
+ """Thin wrapper around the git executable."""
2
+ import subprocess
3
+
4
+
5
+ class GitError(Exception):
6
+ pass
7
+
8
+
9
+ def git(*args, check=True, strip=True):
10
+ """Run git and return stdout (stripped unless strip is False).
11
+
12
+ On failure raise GitError, or return "" when check is False.
13
+ """
14
+ try:
15
+ p = subprocess.run(
16
+ ["git", *args], capture_output=True, text=True, encoding="utf-8", errors="replace"
17
+ )
18
+ except FileNotFoundError:
19
+ raise GitError("git executable not found")
20
+ if p.returncode != 0:
21
+ if check:
22
+ raise GitError(p.stderr.strip() or "git " + " ".join(args) + " failed")
23
+ return ""
24
+ return p.stdout.strip() if strip else p.stdout
@@ -0,0 +1,155 @@
1
+ """`jj log`-style graph (newest on top) of HEAD and the given remote refs.
2
+
3
+ Only the commits that matter are drawn: HEAD, the ref tips and the merge-bases between them.
4
+ Everything in between is folded into a "~ (n)" row on the vertical line.
5
+ """
6
+ from . import color
7
+ from .gitutil import git
8
+
9
+ LINE_GLYPHS = set("│├┴┼╯╮┤─~") # │ ├ ┴ ┼ ╯ ╮ ┤ ─ ~
10
+ LEGEND = "~ (n) = n commits folded between"
11
+
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)
16
+ names = {} # commit -> remote refs pointing at it
17
+ shas = git("rev-parse", *["refs/remotes/" + r for r in refs]).split()
18
+ for ref, sha in zip(refs, shas):
19
+ names.setdefault(sha, []).append(ref)
20
+ tips = sorted(set(names) | {head})
21
+ tip_bit = {c: 1 << i for i, c in enumerate(tips)}
22
+
23
+ # Load the history above the common base: children come before parents in `order`.
24
+ base = git("merge-base", "--octopus", *tips, check=False)
25
+ rev = ["rev-list", "--topo-order", "--parents", *tips] + (["^" + base] if base else [])
26
+ parents = {}
27
+ order = []
28
+ for line in git(*rev).splitlines():
29
+ commit, *ps = line.split()
30
+ parents[commit] = ps
31
+ order.append(commit)
32
+ if base:
33
+ parents[base] = []
34
+ order.append(base)
35
+ for commit in order:
36
+ parents[commit] = [p for p in parents[commit] if p in parents]
37
+
38
+ # mask[c] = which tips can reach c. Commits sharing a mask form one region; the newest commit
39
+ # of each region (no child with the same mask) is where branches split: a merge-base.
40
+ mask = {c: tip_bit.get(c, 0) for c in order}
41
+ for c in order:
42
+ for p in parents[c]:
43
+ mask[p] |= mask[c]
44
+ has_twin_child = {p for c in order for p in parents[c] if mask[p] == mask[c]}
45
+ nodes = [c for c in order if c in tip_bit or c not in has_twin_child]
46
+ node_bit = {c: 1 << i for i, c in enumerate(nodes)}
47
+
48
+ # allanc[c] = bitmask of every node below c; the nearest are those no other ancestor node covers.
49
+ allanc = {}
50
+ for c in reversed(order):
51
+ a = 0
52
+ for p in parents[c]:
53
+ a |= allanc[p] | node_bit.get(p, 0)
54
+ allanc[c] = a
55
+
56
+ def on_head_line(c):
57
+ return c == head or bool(allanc[head] & node_bit[c]) or bool(allanc[c] & node_bit[head])
58
+
59
+ def sort_key(c):
60
+ return (not on_head_line(c), sorted(names.get(c, [c]))[0])
61
+
62
+ above = {} # node -> nearest ancestor nodes, head line first
63
+ folded = {} # node -> commits between it and those ancestors
64
+ for c in nodes:
65
+ a = allanc[c]
66
+ covered = 0
67
+ for n in nodes:
68
+ if a & node_bit[n]:
69
+ covered |= allanc[n]
70
+ above[c] = sorted((n for n in nodes if a & ~covered & node_bit[n]), key=sort_key)
71
+ if above[c]:
72
+ own = int(git("rev-list", "--count", c, *["^" + p for p in above[c]]) or 1)
73
+ folded[c] = own - 1
74
+
75
+ def node_text(c):
76
+ parts = []
77
+ if c == head:
78
+ parts.append(paint(color.GREEN_BOLD, "HEAD -> " + branch if branch else "HEAD (detached)"))
79
+ parts += [paint(color.CYAN, n) for n in sorted(names.get(c, []))]
80
+ if not parts:
81
+ parts.append(paint(color.DIM, "merge-base"))
82
+ text = "%s %s" % (paint(color.DIM, c[:8]), ", ".join(parts))
83
+ return text + " " + head_note if c == head and head_note else text
84
+
85
+ def marker(c):
86
+ if c == head:
87
+ return paint(color.GREEN_BOLD, "@")
88
+ return paint(color.CYAN if c in names else color.DIM, "○") # ○
89
+
90
+ out = []
91
+ lanes = [] # lanes[i] = the node lane i is heading to (None = free)
92
+
93
+ def emit(cells, fill=(), width=0, text=""):
94
+ """One row. cells: lane -> glyph (default: a vertical line on an active lane);
95
+ fill: lanes joined to the next one by a horizontal line."""
96
+ n = max([i for i, c in enumerate(lanes) if c is not None] + [width - 1, *cells]) + 1
97
+ row = ""
98
+ for i in range(n):
99
+ glyph = cells.get(i) or ("│" if i < len(lanes) and lanes[i] else " ")
100
+ row += paint(color.DIM, glyph) if glyph in LINE_GLYPHS else glyph
101
+ row += paint(color.DIM, "─") if i in fill else " "
102
+ out.append(("%s %s" % (row, text)).rstrip())
103
+
104
+ def free_lane(after=-1):
105
+ for i in range(after + 1, len(lanes)):
106
+ if lanes[i] is None:
107
+ return i
108
+ lanes.append(None)
109
+ return len(lanes) - 1
110
+
111
+ def draw(c):
112
+ waiting = [i for i, n in enumerate(lanes) if n == c]
113
+ if not waiting:
114
+ k = free_lane()
115
+ lanes[k] = c
116
+ waiting = [k]
117
+ k, extra = waiting[0], waiting[1:]
118
+ if extra: # lanes of other children converge onto this node
119
+ cells = {k: "├", **{j: "┴" for j in extra}, extra[-1]: "╯"}
120
+ for i in range(k + 1, extra[-1]):
121
+ cells.setdefault(i, "┼" if lanes[i] else "─")
122
+ emit(cells, fill=range(k, extra[-1]))
123
+ for j in extra:
124
+ lanes[j] = None
125
+ emit({k: marker(c)}, width=k + 1, text=node_text(c))
126
+
127
+ ps = above[c]
128
+ lanes[k] = ps[0] if ps else None
129
+ for p in ps[1:]: # merge: a second parent gets its own lane (or joins the one already heading there)
130
+ joined = p in lanes
131
+ j = lanes.index(p) if joined else free_lane(k)
132
+ lanes[j] = p
133
+ lo, hi = min(k, j), max(k, j)
134
+ cells = {lo: "├", hi: "┤" if joined else "╮"}
135
+ for i in range(lo + 1, hi):
136
+ cells[i] = "┼" if lanes[i] else "─"
137
+ emit(cells, fill=range(lo, hi))
138
+ if folded.get(c):
139
+ emit({k: "~"}, text=paint(color.YELLOW, "(%d)" % folded[c]))
140
+
141
+ pending = {c: 0 for c in nodes}
142
+ for c in nodes:
143
+ for a in above[c]:
144
+ pending[a] += 1
145
+ ready = [c for c in nodes if pending[c] == 0]
146
+ while ready:
147
+ ready.sort(key=sort_key)
148
+ c = ready.pop(0)
149
+ draw(c)
150
+ for a in above[c]:
151
+ pending[a] -= 1
152
+ if pending[a] == 0:
153
+ ready.append(a)
154
+ out.append(paint(color.DIM, LEGEND))
155
+ return out
@@ -0,0 +1,40 @@
1
+ """Rank remote refs by how close they are to HEAD."""
2
+ from collections import namedtuple
3
+
4
+ from .gitutil import git
5
+
6
+ REMOTES = "refs/remotes/"
7
+
8
+ Row = namedtuple("Row", "ahead behind ref") # ref is the short name, e.g. origin/main
9
+
10
+
11
+ def _counts_native():
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)
14
+ if not out:
15
+ return None
16
+ return [line.split("\t") for line in out.splitlines()]
17
+
18
+
19
+ def _counts_fallback():
20
+ """One rev-list per ref, for older git."""
21
+ names = git("for-each-ref", "--format=%(refname)", REMOTES).splitlines()
22
+ pairs = []
23
+ for name in names:
24
+ if not name.endswith("/HEAD"):
25
+ pairs.append((git("rev-list", "--left-right", "--count", name + "...HEAD"), name))
26
+ return pairs
27
+
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()
32
+ rows = []
33
+ for counts, name in pairs:
34
+ if name.endswith("/HEAD"): # origin/HEAD is just a pointer
35
+ continue
36
+ # "<only in ref = behind> <only in HEAD = ahead>"
37
+ behind, ahead = counts.split()
38
+ rows.append(Row(int(ahead), int(behind), name[len(REMOTES):]))
39
+ rows.sort(key=lambda r: (r.ahead, r.behind, r.ref))
40
+ return rows[:limit]
@@ -0,0 +1,31 @@
1
+ """Working tree state of HEAD: is it clean, and if not, how dirty."""
2
+ from collections import namedtuple
3
+
4
+ from . import color
5
+ from .gitutil import git
6
+
7
+ Counts = namedtuple("Counts", "staged modified untracked conflicted")
8
+
9
+
10
+ def read():
11
+ counts = dict(staged=0, modified=0, untracked=0, conflicted=0)
12
+ for line in git("status", "--porcelain=v1", strip=False).splitlines():
13
+ x, y = line[0], line[1]
14
+ if x == "!":
15
+ continue
16
+ if x == "?":
17
+ counts["untracked"] += 1
18
+ elif "U" in (x, y) or (x, y) in (("A", "A"), ("D", "D")):
19
+ counts["conflicted"] += 1
20
+ else:
21
+ counts["staged"] += x != " "
22
+ counts["modified"] += y != " "
23
+ return Counts(**counts)
24
+
25
+
26
+ def describe(counts, paint):
27
+ """'[clean]' or e.g. '[2 staged, 3 modified, 1 untracked]'."""
28
+ parts = ["%d %s" % (n, name) for name, n in counts._asdict().items() if n]
29
+ if not parts:
30
+ return paint(color.GREEN, "[clean]")
31
+ return paint(color.RED if counts.conflicted else color.YELLOW, "[%s]" % ", ".join(parts))
@@ -0,0 +1,19 @@
1
+ """The default output: one line per ref with ahead/behind and the merge-base."""
2
+ from . import color
3
+ from .gitutil import git
4
+
5
+
6
+ def render(rows, paint, head_line=None):
7
+ ups = ["↑%d" % r.ahead for r in rows]
8
+ downs = ["↓%d" % r.behind for r in rows]
9
+ wa, wb, wn = max(map(len, ups)), max(map(len, downs)), max(len(r.ref) for r in rows)
10
+ lines = [head_line] if head_line else []
11
+ for r, up, down in zip(rows, ups, downs):
12
+ base = git("merge-base", "refs/remotes/" + r.ref, "HEAD", check=False)[:8]
13
+ lines.append("%s %s %s %s" % (
14
+ paint(color.GREEN, up.ljust(wa)),
15
+ paint(color.RED, down.ljust(wb)),
16
+ r.ref.ljust(wn),
17
+ paint(color.DIM, base),
18
+ ))
19
+ return lines
@@ -0,0 +1,64 @@
1
+ import os
2
+ import subprocess
3
+
4
+ import pytest
5
+
6
+ ENV = {
7
+ "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@example.com",
8
+ "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@example.com",
9
+ "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull,
10
+ }
11
+
12
+
13
+ class Repo:
14
+ def __init__(self, path):
15
+ self.path = str(path)
16
+ self.count = 0 # unique messages: identical parent/tree/message/second would give identical commits
17
+
18
+ def git(self, *args):
19
+ p = subprocess.run(
20
+ ["git", *args], cwd=self.path, env={**os.environ, **ENV},
21
+ capture_output=True, text=True, check=True,
22
+ )
23
+ return p.stdout.strip()
24
+
25
+ def commit(self, n=1):
26
+ for _ in range(n):
27
+ self.count += 1
28
+ self.git("commit", "-q", "--allow-empty", "-m", "c%d" % self.count)
29
+
30
+ def remote(self, name, rev="HEAD"):
31
+ self.git("update-ref", "refs/remotes/" + name, self.git("rev-parse", rev))
32
+
33
+
34
+ @pytest.fixture
35
+ def repo(tmp_path, monkeypatch):
36
+ r = Repo(tmp_path)
37
+ r.git("init", "-q", "-b", "main")
38
+ monkeypatch.chdir(tmp_path)
39
+ for k, v in ENV.items():
40
+ monkeypatch.setenv(k, v)
41
+ return r
42
+
43
+
44
+ @pytest.fixture
45
+ def forked(repo):
46
+ """main and feat fork from a3; feat is merged into main; work branches off main.
47
+
48
+ work: w1 w2
49
+ main: a1 a2 a3 m1 m2 M(merge feat) m3 m4
50
+ feat: a3 f1 f2 f3
51
+ """
52
+ repo.commit(3)
53
+ repo.git("checkout", "-q", "-b", "feat")
54
+ repo.commit(3)
55
+ repo.remote("origin/feat")
56
+ repo.git("checkout", "-q", "main")
57
+ repo.commit(2)
58
+ repo.remote("origin/main")
59
+ repo.git("merge", "-q", "--no-ff", "feat", "-m", "merge feat")
60
+ repo.commit(2)
61
+ repo.git("checkout", "-q", "-b", "work")
62
+ repo.commit(2)
63
+ repo.remote("origin/other", "main~1")
64
+ return repo
@@ -0,0 +1,81 @@
1
+ import re
2
+
3
+ import pytest
4
+
5
+ from git_nearest import refs
6
+ from git_nearest.cli import main
7
+
8
+
9
+ def run(capsys, *argv):
10
+ code = main(["--color", "never", *argv])
11
+ out = capsys.readouterr()
12
+ return code, re.sub(r"\b[0-9a-f]{8}\b", "H", out.out), out.err
13
+
14
+
15
+ def test_table_orders_by_ahead_then_behind(forked, capsys):
16
+ code, out, _ = run(capsys)
17
+ assert code == 0
18
+ assert out.splitlines() == [
19
+ "@ HEAD -> work [clean]",
20
+ "↑3 ↓0 origin/other H",
21
+ "↑7 ↓0 origin/feat H",
22
+ "↑8 ↓0 origin/main H",
23
+ ]
24
+
25
+
26
+ def test_limit(forked, capsys):
27
+ _, out, _ = run(capsys, "1")
28
+ assert out.splitlines() == ["@ HEAD -> work [clean]", "↑3 ↓0 origin/other H"]
29
+
30
+
31
+ def test_behind_is_counted(repo, capsys):
32
+ repo.commit(2)
33
+ repo.git("checkout", "-q", "-b", "side")
34
+ repo.commit(2)
35
+ repo.git("checkout", "-q", "main")
36
+ repo.commit(1)
37
+ repo.remote("origin/main")
38
+ repo.git("checkout", "-q", "side")
39
+ _, out, _ = run(capsys)
40
+ assert out.splitlines()[1] == "↑2 ↓1 origin/main H"
41
+
42
+
43
+ def test_fallback_matches_native(forked):
44
+ assert refs.rank(10, native=False) == refs.rank(10, native=True)
45
+
46
+
47
+ def test_origin_head_is_skipped(forked, capsys):
48
+ forked.git("symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main")
49
+ _, out, _ = run(capsys, "--no-status")
50
+ assert "HEAD" not in out
51
+
52
+
53
+ def test_no_remote_refs(repo, capsys):
54
+ repo.commit()
55
+ code, _, err = run(capsys)
56
+ assert code == 1
57
+ assert "no remote branches" in err
58
+
59
+
60
+ def test_no_commits(repo, capsys):
61
+ code, _, err = run(capsys)
62
+ assert code == 1
63
+ assert err.startswith("git-nearest:")
64
+
65
+
66
+ def test_not_a_repo(tmp_path, monkeypatch, capsys):
67
+ monkeypatch.chdir(tmp_path)
68
+ monkeypatch.setenv("GIT_CEILING_DIRECTORIES", str(tmp_path.parent))
69
+ code, _, err = run(capsys)
70
+ assert code == 1
71
+ assert "not a git repository" in err
72
+
73
+
74
+ def test_bad_limit(forked):
75
+ with pytest.raises(SystemExit):
76
+ main(["0"])
77
+
78
+
79
+ def test_color_always_emits_escapes(forked, capsys):
80
+ main(["--color", "always"])
81
+ assert "\033[" in capsys.readouterr().out
@@ -0,0 +1,72 @@
1
+ import re
2
+
3
+ from git_nearest.cli import main
4
+
5
+
6
+ def graph(capsys, *argv):
7
+ main(["--color", "never", "-g", *argv])
8
+ return re.sub(r"\b[0-9a-f]{8}\b", "H", capsys.readouterr().out).splitlines()
9
+
10
+
11
+ def test_merge_and_fork(forked, capsys):
12
+ assert graph(capsys) == [
13
+ "@ H HEAD -> work [clean]",
14
+ "~ (2)",
15
+ "○ H origin/other",
16
+ "├─╮",
17
+ "~ │ (1)",
18
+ "○ │ H origin/feat",
19
+ "~ │ (2)",
20
+ "│ ○ H origin/main",
21
+ "│ ~ (1)",
22
+ "├─╯",
23
+ "○ H merge-base",
24
+ "~ (n) = n commits folded between",
25
+ ]
26
+
27
+
28
+ def test_linear_history_has_no_lanes(repo, capsys):
29
+ repo.commit(3)
30
+ repo.remote("origin/main")
31
+ repo.commit(4)
32
+ assert graph(capsys) == [
33
+ "@ H HEAD -> main [clean]",
34
+ "~ (3)",
35
+ "○ H origin/main",
36
+ "~ (n) = n commits folded between",
37
+ ]
38
+
39
+
40
+ def test_head_on_a_remote_tip_shares_a_node(repo, capsys):
41
+ repo.commit(2)
42
+ repo.remote("origin/main")
43
+ assert graph(capsys)[0] == "@ H HEAD -> main, origin/main [clean]"
44
+
45
+
46
+ def test_adjacent_nodes_have_no_fold_row(repo, capsys):
47
+ repo.commit(1)
48
+ repo.remote("origin/main")
49
+ repo.commit(1)
50
+ assert graph(capsys) == [
51
+ "@ H HEAD -> main [clean]",
52
+ "○ H origin/main",
53
+ "~ (n) = n commits folded between",
54
+ ]
55
+
56
+
57
+ def test_detached_head(repo, capsys):
58
+ repo.commit(2)
59
+ repo.remote("origin/main")
60
+ repo.git("checkout", "-q", "--detach")
61
+ repo.commit(1)
62
+ assert graph(capsys)[0] == "@ H HEAD (detached) [clean]"
63
+
64
+
65
+ def test_unrelated_histories_do_not_crash(repo, capsys):
66
+ repo.commit(2)
67
+ repo.git("checkout", "-q", "--orphan", "other")
68
+ repo.commit(2)
69
+ repo.remote("origin/main", "main")
70
+ lines = graph(capsys)
71
+ assert any("origin/main" in line for line in lines)
72
+ assert any("HEAD -> other" in line for line in lines)
@@ -0,0 +1,93 @@
1
+ import re
2
+
3
+ from git_nearest import status
4
+ from git_nearest.color import Painter
5
+ from git_nearest.cli import main
6
+
7
+
8
+ def head_line(capsys, *argv):
9
+ main(["--color", "never", *argv])
10
+ return capsys.readouterr().out.splitlines()[0]
11
+
12
+
13
+ def write(repo, name, text="x"):
14
+ with open(repo.path + "/" + name, "w") as f:
15
+ f.write(text)
16
+
17
+
18
+ def tracked(repo, name):
19
+ write(repo, name)
20
+ repo.git("add", name)
21
+ repo.git("commit", "-q", "-m", "add " + name)
22
+
23
+
24
+ def test_clean(forked):
25
+ assert status.read() == status.Counts(0, 0, 0, 0)
26
+
27
+
28
+ def test_counts_each_kind(forked):
29
+ tracked(forked, "a.txt")
30
+ tracked(forked, "b.txt")
31
+ write(forked, "a.txt", "changed") # modified
32
+ write(forked, "b.txt", "changed")
33
+ forked.git("add", "b.txt") # staged
34
+ write(forked, "new.txt") # untracked
35
+ assert status.read() == status.Counts(staged=1, modified=1, untracked=1, conflicted=0)
36
+
37
+
38
+ def test_file_both_staged_and_modified_counts_in_both(forked):
39
+ tracked(forked, "a.txt")
40
+ write(forked, "a.txt", "one")
41
+ forked.git("add", "a.txt")
42
+ write(forked, "a.txt", "two")
43
+ assert status.read() == status.Counts(staged=1, modified=1, untracked=0, conflicted=0)
44
+
45
+
46
+ def test_ignored_files_are_not_counted(forked):
47
+ tracked(forked, ".gitignore")
48
+ write(forked, ".gitignore", "*.log\n")
49
+ forked.git("add", ".gitignore")
50
+ forked.git("commit", "-q", "-m", "ignore")
51
+ write(forked, "x.log")
52
+ assert status.read() == status.Counts(0, 0, 0, 0)
53
+
54
+
55
+ def test_conflict(repo):
56
+ tracked(repo, "f.txt")
57
+ repo.git("checkout", "-q", "-b", "side")
58
+ write(repo, "f.txt", "side")
59
+ repo.git("commit", "-qam", "side")
60
+ repo.git("checkout", "-q", "main")
61
+ write(repo, "f.txt", "main")
62
+ repo.git("commit", "-qam", "main")
63
+ try:
64
+ repo.git("merge", "side")
65
+ except Exception:
66
+ pass
67
+ assert status.read().conflicted == 1
68
+
69
+
70
+ def test_header_line_in_table(forked, capsys):
71
+ tracked(forked, "a.txt")
72
+ write(forked, "a.txt", "changed")
73
+ write(forked, "new.txt")
74
+ assert head_line(capsys) == "@ HEAD -> work [1 modified, 1 untracked]"
75
+
76
+
77
+ def test_graph_marks_head_node(forked, capsys):
78
+ write(forked, "new.txt")
79
+ main(["--color", "never", "-g"])
80
+ first = capsys.readouterr().out.splitlines()[0]
81
+ assert re.sub(r"\b[0-9a-f]{8}\b", "H", first) == "@ H HEAD -> work [1 untracked]"
82
+
83
+
84
+ def test_no_status_skips_it(forked, capsys):
85
+ main(["--color", "never", "--no-status"])
86
+ assert capsys.readouterr().out.splitlines()[0].startswith("↑")
87
+
88
+
89
+ def test_describe_colors():
90
+ p = Painter(True)
91
+ assert "\033[32m[clean]" in status.describe(status.Counts(0, 0, 0, 0), p)
92
+ assert "\033[33m" in status.describe(status.Counts(0, 1, 0, 0), p)
93
+ assert "\033[31m" in status.describe(status.Counts(0, 0, 0, 1), p)