vibefpmgui.git_log

The Git panel's History: the repository's commits, newest first, with their branches and tags, and the graph of how they join (each commit's lane, the lines to its parents), and the two versions of a file a commit changed.

The graph's layout (graph): each lane waits for a commit (a parent of one above it). A commit goes in the lane waiting for it (the leftmost, if several are: the others join it there); then that lane waits for its first parent, and its other parents (a merge's) get lanes of their own, the leftmost free one, unless one is already waiting for them. Lanes keep their places (a free one is used again), so a line that passes a row goes straight down; each lane keeps its color from where it started.

No Qt: vibefpmgui.git_log_view shows it; it's read on a pool thread.

  1"""The Git panel's *History*: the repository's commits, newest first, with
  2their branches and tags, and the graph of how they join (each commit's
  3lane, the lines to its parents), and the two versions of a file a commit
  4changed.
  5
  6The graph's layout (`graph`): each lane waits for a commit (a parent of one
  7above it). A commit goes in the lane waiting for it (the leftmost, if
  8several are: the others join it there); then that lane waits for its first
  9parent, and its other parents (a merge's) get lanes of their own, the
 10leftmost free one, unless one is already waiting for them. Lanes keep their
 11places (a free one is used again), so a line that passes a row goes
 12straight down; each lane keeps its color from where it started.
 13
 14No Qt: `vibefpmgui.git_log_view` shows it; it's read on a pool thread.
 15"""
 16
 17from __future__ import annotations
 18
 19from dataclasses import dataclass, field
 20from pathlib import Path
 21
 22from .git_blame import FileChange
 23from .git_status import BINARY, MAX_DIFF_BYTES, _check, _git, _text, find_repository
 24
 25MAX_COMMITS = 3000  # read at first, at most (the newest; the rest: the History's Load Older Commits)
 26
 27# Ref kinds, as their badges show them.
 28HEAD, BRANCH, REMOTE, TAG = "head", "branch", "remote", "tag"
 29
 30
 31@dataclass
 32class Ref:
 33    name: str            # "main", "origin/main", "v1.0", "HEAD" (detached)
 34    kind: str            # HEAD, BRANCH, REMOTE or TAG
 35    current: bool = False  # the branch checked out (HEAD -> it)
 36
 37
 38@dataclass
 39class LogCommit:
 40    sha: str
 41    parents: list[str]
 42    author: str
 43    email: str
 44    time: int             # its author's date (seconds since 1970)
 45    subject: str
 46    refs: list[Ref] = field(default_factory=list)
 47
 48
 49@dataclass
 50class GraphRow:
 51    """A commit's row of the graph: lanes are numbered from the left, each
 52    line given as (lane, color number)."""
 53    lane: int                     # the commit's
 54    color: int                    # its lane's
 55    from_above: bool              # a line comes down into it (it's a parent of a commit above)
 56    through: list[tuple[int, int]]  # lanes passing by (top to bottom)
 57    joining: list[tuple[int, int]]  # lanes ending in it (from the top: a branch that started here)
 58    parents: list[tuple[int, int]]  # lanes leaving it (to the bottom: its parents')
 59    width: int                    # lanes in use, above or below it
 60
 61
 62@dataclass
 63class Log:
 64    root: Path
 65    head: str                     # the commit checked out
 66    commits: list[LogCommit]
 67    rows: list[GraphRow]
 68    all_branches: bool
 69    limited: bool                 # more commits than the limit: the newest shown
 70    remote: str = ""              # origin's address (the commits' web pages)
 71    total: int = 0                # the commits there are (more than those listed, if limited)
 72
 73
 74def parse_refs(decoration: str) -> list[Ref]:
 75    """The refs in ``%D`` (with ``--decorate=full``): "HEAD -> refs/heads/main,
 76    tag: refs/tags/v1, refs/remotes/origin/main". The branch checked out
 77    first, then the others, the remote ones, the tags."""
 78    refs = []
 79    for part in (p.strip() for p in decoration.split(",")):
 80        current = part.startswith("HEAD -> ")
 81        part = part.removeprefix("HEAD -> ")
 82        if part == "HEAD":
 83            refs.append(Ref("HEAD", HEAD, True))  # (detached)
 84        elif part.startswith("tag: refs/tags/"):
 85            refs.append(Ref(part.removeprefix("tag: refs/tags/"), TAG))
 86        elif part.startswith("refs/heads/"):
 87            refs.append(Ref(part.removeprefix("refs/heads/"), BRANCH, current))
 88        elif part.startswith("refs/remotes/") and not part.endswith("/HEAD"):
 89            refs.append(Ref(part.removeprefix("refs/remotes/"), REMOTE))
 90    order = {HEAD: 0, BRANCH: 1, REMOTE: 2, TAG: 3}
 91    return sorted(refs, key=lambda r: (not r.current, order[r.kind]))
 92
 93
 94FIELDS = "%H%x1f%P%x1f%an%x1f%ae%x1f%at%x1f%D%x1f%s%x1e"
 95
 96
 97def parse_log(output: bytes) -> list[LogCommit]:
 98    """``git log --format=FIELDS --decorate=full`` read."""
 99    commits = []
100    for record in output.decode("utf-8", "replace").split("\x1e"):
101        parts = record.strip("\n").split("\x1f")
102        if len(parts) != 7 or len(parts[0]) < 7:
103            continue
104        sha, parents, author, email, when, refs, subject = parts
105        commits.append(LogCommit(sha, parents.split(), author, email, int(when) if when.isdigit() else 0, subject,
106                                 parse_refs(refs)))
107    return commits
108
109
110def graph(commits: list[LogCommit]) -> list[GraphRow]:
111    """Each commit's row of the graph (see the module's description)."""
112    lanes: list[str | None] = []  # the commit each lane waits for
113    colors: list[int] = []
114    next_color = 0
115    rows = []
116
117    def free_lane(avoid: int = -1) -> int:
118        for i, waiting in enumerate(lanes):
119            if waiting is None and i != avoid:
120                return i
121        lanes.append(None)
122        colors.append(0)
123        return len(lanes) - 1
124
125    for commit in commits:
126        above = len(lanes)
127        from_above = commit.sha in lanes
128        if from_above:
129            lane = lanes.index(commit.sha)
130        else:
131            lane = free_lane()
132            lanes[lane], colors[lane] = commit.sha, next_color
133            next_color += 1
134        joining = [(i, colors[i]) for i, waiting in enumerate(lanes) if waiting == commit.sha and i != lane]
135        through = [(i, colors[i]) for i, waiting in enumerate(lanes) if waiting is not None and waiting != commit.sha]
136        for i, _color in joining:
137            lanes[i] = None
138        parents = []
139        if commit.parents:
140            lanes[lane] = commit.parents[0]
141            parents.append((lane, colors[lane]))
142            for parent in commit.parents[1:]:
143                if parent in lanes:
144                    i = lanes.index(parent)
145                else:
146                    i = free_lane(avoid=lane)
147                    lanes[i], colors[i] = parent, next_color
148                    next_color += 1
149                parents.append((i, colors[i]))
150        else:
151            lanes[lane] = None  # (the first commit: nothing below)
152        rows.append(GraphRow(lane, colors[lane], from_above, through, joining, parents, max(above, len(lanes))))
153        while lanes and lanes[-1] is None:
154            lanes.pop()
155            colors.pop()
156    return rows
157
158
159def read_log(path: Path, all_branches: bool = True, limit: int | None = MAX_COMMITS) -> Log | None:
160    """The history of the repository ``path`` is in: every branch's (local,
161    remote) and tag's, or only what's checked out (None: not in a
162    repository, or no commits yet); the newest ``limit`` commits (None:
163    all of them, to the first). Raises GitError if git can't read it."""
164    root = find_repository(path)
165    if root is None:
166        return None
167    code, head, _message = _git(root, "rev-parse", "--verify", "-q", "HEAD")
168    if code != 0 or not head or not head.strip():
169        return None
170    which = ["--branches", "--remotes", "--tags", "HEAD"] if all_branches else ["HEAD"]
171    most = [] if limit is None else [f"--max-count={limit + 1}"]
172    output = _check(root, "log", f"--format={FIELDS}", "--decorate=full", "--date-order", *most, *which, "--")
173    commits = parse_log(output)
174    limited = limit is not None and len(commits) > limit
175    total = len(commits)
176    if limited:
177        commits = commits[:limit]
178        total = int(_check(root, "rev-list", "--count", *which, "--").strip() or 0)  # (how many more there are)
179    _code, remote, _message = _git(root, "config", "--get", "remote.origin.url")
180    return Log(root, head.decode().strip(), commits, graph(commits), all_branches, limited,
181               (remote or b"").decode("utf-8", "replace").strip(), total)
182
183
184def file_versions(root: Path, commit: LogCommit, change: FileChange) -> tuple[str | None, str | None]:
185    """(before, after) a commit of a file it changed: the text in its first
186    parent (None: added, or the first commit), and in it (None: deleted).
187    BINARY: it isn't text, or it's huge."""
188
189    def blob(spec: str) -> str | None:
190        code, output, _message = _git(root, "cat-file", "blob", spec, limit=MAX_DIFF_BYTES)
191        if code != 0:
192            return None
193        return BINARY if output is None else _text(output)
194
195    parent = commit.parents[0] if commit.parents else None
196    before = blob(f"{parent}:{change.old_path or change.path}") if parent and change.status != "A" else None
197    after = blob(f"{commit.sha}:{change.path}") if change.status != "D" else None
198    return before, after
MAX_COMMITS = 3000
@dataclass
class Ref:
32@dataclass
33class Ref:
34    name: str            # "main", "origin/main", "v1.0", "HEAD" (detached)
35    kind: str            # HEAD, BRANCH, REMOTE or TAG
36    current: bool = False  # the branch checked out (HEAD -> it)
Ref(name: str, kind: str, current: bool = False)
name: str
kind: str
current: bool = False
@dataclass
class LogCommit:
39@dataclass
40class LogCommit:
41    sha: str
42    parents: list[str]
43    author: str
44    email: str
45    time: int             # its author's date (seconds since 1970)
46    subject: str
47    refs: list[Ref] = field(default_factory=list)
LogCommit( sha: str, parents: list[str], author: str, email: str, time: int, subject: str, refs: list[Ref] = <factory>)
sha: str
parents: list[str]
author: str
email: str
time: int
subject: str
refs: list[Ref]
@dataclass
class GraphRow:
50@dataclass
51class GraphRow:
52    """A commit's row of the graph: lanes are numbered from the left, each
53    line given as (lane, color number)."""
54    lane: int                     # the commit's
55    color: int                    # its lane's
56    from_above: bool              # a line comes down into it (it's a parent of a commit above)
57    through: list[tuple[int, int]]  # lanes passing by (top to bottom)
58    joining: list[tuple[int, int]]  # lanes ending in it (from the top: a branch that started here)
59    parents: list[tuple[int, int]]  # lanes leaving it (to the bottom: its parents')
60    width: int                    # lanes in use, above or below it

A commit's row of the graph: lanes are numbered from the left, each line given as (lane, color number).

GraphRow( lane: int, color: int, from_above: bool, through: list[tuple[int, int]], joining: list[tuple[int, int]], parents: list[tuple[int, int]], width: int)
lane: int
color: int
from_above: bool
through: list[tuple[int, int]]
joining: list[tuple[int, int]]
parents: list[tuple[int, int]]
width: int
@dataclass
class Log:
63@dataclass
64class Log:
65    root: Path
66    head: str                     # the commit checked out
67    commits: list[LogCommit]
68    rows: list[GraphRow]
69    all_branches: bool
70    limited: bool                 # more commits than the limit: the newest shown
71    remote: str = ""              # origin's address (the commits' web pages)
72    total: int = 0                # the commits there are (more than those listed, if limited)
Log( root: pathlib.Path, head: str, commits: list[LogCommit], rows: list[GraphRow], all_branches: bool, limited: bool, remote: str = '', total: int = 0)
root: pathlib.Path
head: str
commits: list[LogCommit]
rows: list[GraphRow]
all_branches: bool
limited: bool
remote: str = ''
total: int = 0
def parse_refs(decoration: str) -> list[Ref]:
75def parse_refs(decoration: str) -> list[Ref]:
76    """The refs in ``%D`` (with ``--decorate=full``): "HEAD -> refs/heads/main,
77    tag: refs/tags/v1, refs/remotes/origin/main". The branch checked out
78    first, then the others, the remote ones, the tags."""
79    refs = []
80    for part in (p.strip() for p in decoration.split(",")):
81        current = part.startswith("HEAD -> ")
82        part = part.removeprefix("HEAD -> ")
83        if part == "HEAD":
84            refs.append(Ref("HEAD", HEAD, True))  # (detached)
85        elif part.startswith("tag: refs/tags/"):
86            refs.append(Ref(part.removeprefix("tag: refs/tags/"), TAG))
87        elif part.startswith("refs/heads/"):
88            refs.append(Ref(part.removeprefix("refs/heads/"), BRANCH, current))
89        elif part.startswith("refs/remotes/") and not part.endswith("/HEAD"):
90            refs.append(Ref(part.removeprefix("refs/remotes/"), REMOTE))
91    order = {HEAD: 0, BRANCH: 1, REMOTE: 2, TAG: 3}
92    return sorted(refs, key=lambda r: (not r.current, order[r.kind]))

The refs in %D (with --decorate=full): "HEAD -> refs/heads/main, tag: refs/tags/v1, refs/remotes/origin/main". The branch checked out first, then the others, the remote ones, the tags.

FIELDS = '%H%x1f%P%x1f%an%x1f%ae%x1f%at%x1f%D%x1f%s%x1e'
def parse_log(output: bytes) -> list[LogCommit]:
 98def parse_log(output: bytes) -> list[LogCommit]:
 99    """``git log --format=FIELDS --decorate=full`` read."""
100    commits = []
101    for record in output.decode("utf-8", "replace").split("\x1e"):
102        parts = record.strip("\n").split("\x1f")
103        if len(parts) != 7 or len(parts[0]) < 7:
104            continue
105        sha, parents, author, email, when, refs, subject = parts
106        commits.append(LogCommit(sha, parents.split(), author, email, int(when) if when.isdigit() else 0, subject,
107                                 parse_refs(refs)))
108    return commits

git log --format=FIELDS --decorate=full read.

def graph( commits: list[LogCommit]) -> list[GraphRow]:
111def graph(commits: list[LogCommit]) -> list[GraphRow]:
112    """Each commit's row of the graph (see the module's description)."""
113    lanes: list[str | None] = []  # the commit each lane waits for
114    colors: list[int] = []
115    next_color = 0
116    rows = []
117
118    def free_lane(avoid: int = -1) -> int:
119        for i, waiting in enumerate(lanes):
120            if waiting is None and i != avoid:
121                return i
122        lanes.append(None)
123        colors.append(0)
124        return len(lanes) - 1
125
126    for commit in commits:
127        above = len(lanes)
128        from_above = commit.sha in lanes
129        if from_above:
130            lane = lanes.index(commit.sha)
131        else:
132            lane = free_lane()
133            lanes[lane], colors[lane] = commit.sha, next_color
134            next_color += 1
135        joining = [(i, colors[i]) for i, waiting in enumerate(lanes) if waiting == commit.sha and i != lane]
136        through = [(i, colors[i]) for i, waiting in enumerate(lanes) if waiting is not None and waiting != commit.sha]
137        for i, _color in joining:
138            lanes[i] = None
139        parents = []
140        if commit.parents:
141            lanes[lane] = commit.parents[0]
142            parents.append((lane, colors[lane]))
143            for parent in commit.parents[1:]:
144                if parent in lanes:
145                    i = lanes.index(parent)
146                else:
147                    i = free_lane(avoid=lane)
148                    lanes[i], colors[i] = parent, next_color
149                    next_color += 1
150                parents.append((i, colors[i]))
151        else:
152            lanes[lane] = None  # (the first commit: nothing below)
153        rows.append(GraphRow(lane, colors[lane], from_above, through, joining, parents, max(above, len(lanes))))
154        while lanes and lanes[-1] is None:
155            lanes.pop()
156            colors.pop()
157    return rows

Each commit's row of the graph (see the module's description).

def read_log( path: pathlib.Path, all_branches: bool = True, limit: int | None = 3000) -> Log | None:
160def read_log(path: Path, all_branches: bool = True, limit: int | None = MAX_COMMITS) -> Log | None:
161    """The history of the repository ``path`` is in: every branch's (local,
162    remote) and tag's, or only what's checked out (None: not in a
163    repository, or no commits yet); the newest ``limit`` commits (None:
164    all of them, to the first). Raises GitError if git can't read it."""
165    root = find_repository(path)
166    if root is None:
167        return None
168    code, head, _message = _git(root, "rev-parse", "--verify", "-q", "HEAD")
169    if code != 0 or not head or not head.strip():
170        return None
171    which = ["--branches", "--remotes", "--tags", "HEAD"] if all_branches else ["HEAD"]
172    most = [] if limit is None else [f"--max-count={limit + 1}"]
173    output = _check(root, "log", f"--format={FIELDS}", "--decorate=full", "--date-order", *most, *which, "--")
174    commits = parse_log(output)
175    limited = limit is not None and len(commits) > limit
176    total = len(commits)
177    if limited:
178        commits = commits[:limit]
179        total = int(_check(root, "rev-list", "--count", *which, "--").strip() or 0)  # (how many more there are)
180    _code, remote, _message = _git(root, "config", "--get", "remote.origin.url")
181    return Log(root, head.decode().strip(), commits, graph(commits), all_branches, limited,
182               (remote or b"").decode("utf-8", "replace").strip(), total)

The history of the repository path is in: every branch's (local, remote) and tag's, or only what's checked out (None: not in a repository, or no commits yet); the newest limit commits (None: all of them, to the first). Raises GitError if git can't read it.

def file_versions( root: pathlib.Path, commit: LogCommit, change: vibefpmgui.git_blame.FileChange) -> tuple[str | None, str | None]:
185def file_versions(root: Path, commit: LogCommit, change: FileChange) -> tuple[str | None, str | None]:
186    """(before, after) a commit of a file it changed: the text in its first
187    parent (None: added, or the first commit), and in it (None: deleted).
188    BINARY: it isn't text, or it's huge."""
189
190    def blob(spec: str) -> str | None:
191        code, output, _message = _git(root, "cat-file", "blob", spec, limit=MAX_DIFF_BYTES)
192        if code != 0:
193            return None
194        return BINARY if output is None else _text(output)
195
196    parent = commit.parents[0] if commit.parents else None
197    before = blob(f"{parent}:{change.old_path or change.path}") if parent and change.status != "A" else None
198    after = blob(f"{commit.sha}:{change.path}") if change.status != "D" else None
199    return before, after

(before, after) a commit of a file it changed: the text in its first parent (None: added, or the first commit), and in it (None: deleted). BINARY: it isn't text, or it's huge.