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.
- git_nearest-0.1.0/.gitignore +6 -0
- git_nearest-0.1.0/LICENSE +21 -0
- git_nearest-0.1.0/PKG-INFO +128 -0
- git_nearest-0.1.0/README.md +110 -0
- git_nearest-0.1.0/pyproject.toml +42 -0
- git_nearest-0.1.0/src/git_nearest/__init__.py +3 -0
- git_nearest-0.1.0/src/git_nearest/__main__.py +5 -0
- git_nearest-0.1.0/src/git_nearest/cli.py +64 -0
- git_nearest-0.1.0/src/git_nearest/color.py +18 -0
- git_nearest-0.1.0/src/git_nearest/gitutil.py +24 -0
- git_nearest-0.1.0/src/git_nearest/graph.py +155 -0
- git_nearest-0.1.0/src/git_nearest/refs.py +40 -0
- git_nearest-0.1.0/src/git_nearest/status.py +31 -0
- git_nearest-0.1.0/src/git_nearest/table.py +19 -0
- git_nearest-0.1.0/tests/conftest.py +64 -0
- git_nearest-0.1.0/tests/test_cli.py +81 -0
- git_nearest-0.1.0/tests/test_graph.py +72 -0
- git_nearest-0.1.0/tests/test_status.py +93 -0
|
@@ -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,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)
|