Re: [PATCH RFC v3 2/2] Move libgit.a sources into separate "lib/" directory

Michael Montalbo <[email protected]>
Newsgroups org.kernel.vger.git
Message-ID <CAC2QwmKSrDN1s9a1dR3q165+6LxSrVksY+KWn284ehYrthZ98A@mail.gmail.com>
On Thu, Aug 13, 2026 at 12:04 PM Michael Montalbo <[email protected]> wrote:
>
> One mechanical signal is how tightly a set of files shares the same
> internal headers: if two .c files include many of the same project
> headers (dropping the plumbing everything includes), they lean on the
> same machinery. Averaged over every pair in the group, that gives one
> cohesion number.
>

I spent some time trying to think about a stronger approach. I think the
following script[2] is a more promising approach to criticize:

- Read the Makefile. Collect the C files that it compiles into the git
  library. The count is 231.
- Read every non-merge commit in the history, up to HEAD. For each
  one, take its area name (the subject prefix, such as "diff:" or
  "refs:") and the library files it changed.
- Give each commit a weight of one divided by the number of library
  files it changed. A commit that changed two files counts more than a
  commit that changed forty.
- For each file, take the area name with the largest total weight. If
  that weight is small, the file has no area name.
- Read a 14-line table that groups area names into directories. Put
  each file into the directory for its area name, or, if it has none,
  the directory for its filename. A file that matches no directory
  stays ungrouped.
- Build a call graph with cscope, a cross-reference database that
  records which file uses functions defined in another file[1]. This is
  based on the code, not on file or commit names, so it is a second
  check independent of the first.
- Print each directory with its files, then the ungrouped files. Last,
  compare the call groups with the directories and show the groups
  that fall across more than one directory. Those mark the boundary
  files to check.

The 14-entry table that maps area names to directories is the
CHARTER block in the script below. Its output:

git library source files by directory  (HEAD c0c95f3229; 231 files)

odb/   21 files  (21 by commit label)
   blob commit fsck hash loose match-trees object object-file
   object-file-convert object-name odb oid-array oidmap oidset oidtree
   replace-object sha1dc_git tag tmp-objdir tree tree-walk

refs/   8 files  (7 by commit label)
   ls-refs pack-refs ref-filter reflog reflog-walk refs refspec
   worktree

pack/   27 files  (18 by commit label)
   bloom chunk-format commit-graph delta-islands diff-delta midx
   midx-write pack-bitmap pack-bitmap-write pack-check pack-mtimes
   pack-objects pack-revindex pack-write packfile packfile-list
   patch-delta prune-packed pseudo-merge reachable repack repack-cruft
   repack-filtered repack-geometry repack-midx repack-promisor
   server-info

diff/   16 files  (14 by commit label)
   combine-diff diff diff-lib diff-merges diff-no-index diffcore-break
   diffcore-delta diffcore-order diffcore-pickaxe diffcore-rename
   diffcore-rotate patch-ids range-diff tree-diff userdiff
   xdiff-interface

merge/   7 files  (7 by commit label)
   fmt-merge-msg merge merge-blobs merge-ll merge-ort
   merge-ort-wrappers rerere

revision/   14 files  (12 by commit label)
   bisect blame commit-reach decorate graph line-log line-range
   list-objects list-objects-filter list-objects-filter-options
   log-tree pretty revision shallow

index/   19 files  (18 by commit label)
   cache-tree checkout dir entry fsmonitor fsmonitor-ipc
   fsmonitor-settings name-hash parallel-checkout pathspec
   preload-index read-cache resolve-undo sparse-index split-index
   statinfo symlinks unpack-trees wt-status

setup/   9 files  (9 by commit label)
   alias config environment hook ident repo-settings repository setup
   version

convert/   3 files  (3 by commit label)
   attr convert ws

transport/   19 files  (17 by commit label)
   bundle bundle-uri connect connected fetch-negotiator
   fetch-object-info fetch-pack pkt-line promisor-remote protocol
   protocol-caps remote send-pack serve sideband transport
   transport-helper upload-pack walker

notes/   4 files  (3 by commit label)
   notes notes-cache notes-merge notes-utils

submodule/   2 files  (2 by commit label)
   submodule submodule-config

archive/   3 files  (3 by commit label)
   archive archive-tar archive-zip

sequencer/   11 files  (11 by commit label)
   add-interactive add-patch apply branch mailinfo mailmap rebase
   rebase-interactive replay reset sequencer

placed 163 files in 14 directories: 145 by commit label,
12 by filename, 6 by override. The table is 14 lines.

68 files match no subsystem and are left ungrouped:
   abspath advice alloc base85 cbtree chdir-notify color column
   common-exit common-init copy credential csum-file ctype date
   diagnose dir-iterator editor exec-cmd gettext git-zlib
   gpg-interface grep hash-lookup hashmap help hex hex-ll json-writer
   kwset levenshtein linear-assignment lockfile mem-pool pager parse
   parse-options parse-options-cb path path-walk prio-queue progress
   prompt quote run-command sigchain stable-qsort strbuf string-list
   strmap strvec sub-process tempfile thread-utils trace trace2
   trailer unix-socket unix-stream-server url urlmatch usage utf8
   varint versioncmp wildmatch wrapper write-or-die

[1] Detail: the call-graph check and the label-vs-filename comparison:

Second check. The call graph records which file uses functions from
another file, so it does not use names. Group the files that call each
other, then compare each group with the directories above. 57 files
fall into 12 call groups. The 12 sort into three kinds:
  5 groups: all placed files land in one directory (call graph agrees).
  6 groups: most files in one directory, plus one or two
     from another (the boundary cases to check).
  1 group: shared helpers that many files call (not shown).

Boundary groups, largest directory first. Each line is one
directory and its files in the group. Example: shallow is in
revision, but the call graph groups it with the transport files.

  transport (5): connect fetch-pack pkt-line send-pack upload-pack
  revision (1): shallow

  transport (3): remote transport transport-helper
  refs (1): refspec

  odb (2): hash object-file
  pack (1): pack-write

  pack (2): chunk-format commit-graph
  revision (2): commit-reach revision
  odb (1): commit

  sequencer (2): add-interactive add-patch
  index (1): wt-status

  setup (2): config setup
  refs (1): worktree

Commit label vs filename: both point to the same directory for 151 files
and disagree for 0 (the 8 overrides are separate). The commit
label and the filename follow the same naming rule, so the label
confirms the naming, not the grouping. Only the call graph is an
independent check.

[2] The script:

#!/usr/bin/env python3
"""subsystems.py: group git's libgit.a source files into directories.

Steps:
  membership : the Makefile lists the library source files (LIB_OBJS).
  label      : each file's directory is the area name used most often
               in its commit messages, weighted toward small commits.
  table      : 14 lines say which area names share a directory.
  check      : the call graph is a second, independent signal; it
               lists the files where the two signals disagree.

Run from a git.git checkout (uses HEAD).
"""
import re
import os
import shutil
import tempfile
import subprocess
import math
import textwrap
from collections import defaultdict, Counter

TOP = subprocess.check_output(
    ["git", "rev-parse", "--show-toplevel"], text=True).strip()
HEAD = subprocess.check_output(
    ["git", "-C", TOP, "rev-parse", "--short", "HEAD"],
    text=True).strip()


def git(*a):
    return subprocess.run(["git", "-C", TOP, *a],
                          capture_output=True, text=True).stdout


# membership: exactly what libgit.a archives (authoritative)
LIB = set()
for line in open(TOP + "/Makefile"):
    m = re.match(r"LIB_OBJS \+= ([\w-]+)\.o$", line.strip())
    if m and "/" not in m.group(1):
        LIB.add(m.group(1) + ".c")

# charter: the only hand input, which fine areas share a directory
CHARTER = {
    "odb": "object odb oid oidmap oidset oidtree loose blob tag "
           "commit tree replace match tmp fsck hash sha1dc",
    "refs": "refs refspec reflog ref ls worktree",
    "pack": "pack packfile midx delta prune reachable server bloom "
            "chunk pseudo repack",
    "diff": "diff diffcore combine range userdiff xdiff patch pickaxe",
    "merge": "merge rerere fmt",
    "revision": "revision log bisect shallow blame line graph "
                "decorate list pretty",
    "index": "read cache split sparse unpack name preload resolve "
             "statinfo entry parallel dir pathspec checkout symlinks "
             "fsmonitor wt",
    "setup": "config setup environment repository repo ident alias "
             "version hook",
    "convert": "attr convert ws whitespace",
    "transport": "transport remote connect connected send fetch "
                 "upload walker http protocol serve pkt sideband "
                 "bundle promisor",
    "notes": "notes", "submodule": "submodule", "archive": "archive",
    "sequencer": "sequencer rebase replay apply add reset branch "
                 "mailinfo mailmap",
}
OWNER = {t: s for s, toks in CHARTER.items() for t in toks.split()}
# per-file exceptions where the name or label points at the wrong dir
OVERRIDE = {"pack-refs.c": "refs", "commit-graph.c": "pack",
            "commit-reach.c": "revision", "tree-diff.c": "diff",
            "diff-delta.c": "pack", "patch-delta.c": "pack",
            "dir-iterator.c": "lib", "hash-lookup.c": "lib"}


def to_dir(token):
    return OWNER.get(token)


# signal A: focus-weighted modal commit "area:" label
# No noise list. A sweep label such as treewide or global appears only
# on commits that change many files. Small commit weight keeps those
# commits low, so a sweep label is never a file's top label; 0 seen.
PRE = re.compile(r"^([A-Za-z0-9][\w./-]*):")


def norm(p):
    p = p.lower()
    return p[:-2] if p.endswith((".c", ".h")) else p


# One pass over the log. A commit touching k libgit.a files adds 1/k to
# each file's tally for that commit's area label, so a 2-file commit is
# stronger evidence than a 40-file sweep. (History before a rename is
# under the old name; a --follow pass is a known refinement.)
commits = []
lab, files = None, []
log = git("log", "--no-merges", "--name-only", "--format=%x00%s")
for line in log.split("\n"):
    if line.startswith("\x00"):
        if lab and files:
            commits.append((lab, files))
        m = PRE.match(line[1:])
        lab = norm(m.group(1)) if m else None
        files = []
    elif line.endswith(".c") and "/" not in line and line in LIB:
        files.append(line)
if lab and files:
    commits.append((lab, files))

wt = defaultdict(lambda: defaultdict(float))
for lab, files in commits:
    w = 1.0 / len(files)
    for f in set(files):
        wt[f][lab] += w


def modal(f):
    """The top focus-weighted label, or None if history is thin."""
    c = wt.get(f)
    if not c:
        return None
    l, w = max(c.items(), key=lambda kv: (kv[1], kv[0]))  # tie: name
    tot = sum(c.values())
    if tot < 2.0 or w / tot < 0.34:   # <2 commits, or under 34%
        return None
    return l


def place(f):
    """(dir, how) for a file, or (None, ...) if it joins no subsystem.
    Precedence: override, commit label, filename."""
    if f in OVERRIDE:
        d = OVERRIDE[f]
        return (None if d == "lib" else d), "override"
    lab = modal(f)
    if lab:
        d = to_dir(lab.split("/")[0].split("-")[0])
        if d:
            return d, "label"
    d = to_dir(f[:-2].split("-")[0])     # fallback: filename token
    if d:
        return d, "name"
    return None, "ungrouped"


# signal B: the call graph. cscope, a cross-reference database, gives
# the call edges, so calls in comments, strings, and macros are not
# counted. The coding style gives the definitions: a column-0 line that
# names a function defines it; it is exported if it lacks "static".
CTRL = {"if", "for", "while", "switch", "return", "sizeof", "do",
        "else", "case", "typedef", "struct", "union", "enum", "extern",
        "static"}
text = {f: open(TOP + "/" + f, encoding="utf-8",
                errors="replace").read() for f in LIB}


def scan_defs(t):
    """(exported, all) function names a file defines at column 0."""
    exported, everything = set(), set()
    for ln in t.split("\n"):
        if not ln or ln[0] in " \t#}/*{)":
            continue
        if ("(" not in ln or ln.rstrip().endswith(";")
                or "=" in ln.split("(")[0]):
            continue
        m = re.match(r"^(static\b)?.*?([A-Za-z_]\w*)\s*\(", ln)
        if not m or m.group(2) in CTRL:
            continue
        everything.add(m.group(2))
        if not m.group(1):
            exported.add(m.group(2))
    return exported, everything


owner_fn, dup, allnames = {}, set(), set()
for f in LIB:
    exported, everything = scan_defs(text[f])
    allnames |= everything
    for name in exported:
        if name in owner_fn:
            dup.add(name)
        owner_fn[name] = f
for name in dup:
    owner_fn.pop(name, None)


def cscope_calls():
    """calls[a][b] = call sites in root .c file a that call a non-static
    function defined in root .c file b, using cscope. cscope type 2
    lists, per queried function, the functions it calls, one line per
    call site as 'caller_file callee_name line text'. The caller file
    and callee name are on each line, so no bookkeeping is needed."""
    if not shutil.which("cscope"):
        raise SystemExit("cscope not found on PATH; run under "
                         "'nix-shell -p cscope'.")
    tmp = tempfile.mkdtemp(prefix="subsys-cs-")
    try:
        flist = os.path.join(tmp, "files")
        out = os.path.join(tmp, "cscope.out")
        with open(flist, "w") as fh:
            fh.write("\n".join(sorted(LIB)) + "\n")
        subprocess.run(
            ["cscope", "-b", "-q", "-k", "-i", flist, "-f", out],
            cwd=TOP, check=True, stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL)
        query = "".join(f"2{n}\n" for n in sorted(allnames))
        res = subprocess.run(
            ["cscope", "-d", "-l", "-f", out], cwd=TOP, input=query,
            capture_output=True, text=True).stdout
    finally:
        shutil.rmtree(tmp, ignore_errors=True)
    calls = defaultdict(lambda: defaultdict(int))
    for line in res.split("\n"):
        if not line or line.startswith(">>"):   # blank or prompt line
            continue
        parts = line.split(" ", 3)   # caller_file callee line text
        if len(parts) < 4 or not parts[2].isdigit():
            continue   # marker or malformed line
        caller, callee = parts[0], parts[1]
        cf = owner_fn.get(callee)
        if caller in LIB and cf and cf != caller:
            calls[caller][cf] += 1
    return calls


calls = cscope_calls()
fanin = defaultdict(set)
for a in calls:
    for b in calls[a]:
        fanin[b].add(a)
N = len(LIB)
idf = {b: math.log(N / len(s)) for b, s in fanin.items()}
aff = defaultdict(dict)
for a in calls:
    for b in calls[a]:
        w = (calls[a].get(b, 0) * idf.get(b, 0)
             + calls.get(b, {}).get(a, 0) * idf.get(a, 0))
        if w > 0:
            aff[a][b] = aff[b][a] = w
idx = {f: i for i, f in enumerate(sorted(LIB))}
mem = {i: [f] for f, i in idx.items()}
size = {i: 1 for i in mem}
link = defaultdict(dict)
for a in aff:
    for b, w in aff[a].items():
        if idx[a] < idx[b]:
            link[idx[a]][idx[b]] = link[idx[b]][idx[a]] = w
act, nid = set(mem), len(LIB)
while True:
    best, bv = None, 15.0
    for i in act:
        for j, w in link[i].items():
            if i < j and w / (size[i] * size[j]) >= bv:
                bv, best = w / (size[i] * size[j]), (i, j)
    if not best:
        break
    i, j = best
    c = nid
    nid += 1
    mem[c], size[c] = mem[i] + mem[j], size[i] + size[j]
    for x in (set(link[i]) | set(link[j])) - {i, j}:
        link[c][x] = link[x][c] = link[i].get(x, 0) + link[j].get(x, 0)
    for x in list(link[i]):
        link[x].pop(i, None)
    for x in list(link[j]):
        link[x].pop(j, None)
    link.pop(i, None)
    link.pop(j, None)
    act.discard(i)
    act.discard(j)
    act.add(c)
clusters = [mem[c] for c in act if size[c] >= 3]

# report
pl = {f: place(f) for f in sorted(LIB)}
by_dir = defaultdict(list)
for f, (d, _how) in pl.items():
    by_dir[d].append(f)


def wrapped(names):
    return textwrap.wrap(" ".join(sorted(n[:-2] for n in names)), 70,
                         initial_indent="   ", subsequent_indent="   ",
                         break_long_words=False, break_on_hyphens=False)


mod = git("status", "--porcelain", "--",
          "*.c", "*.h", "Makefile").strip()
dirty = " plus local changes" if mod else ""
print(f"git library source files by directory  "
      f"(HEAD {HEAD}{dirty}; {len(LIB)} files)\n")
for d in list(CHARTER):
    fs = by_dir.get(d, [])
    if not fs:
        continue
    lab_n = sum(1 for f in fs if pl[f][1] == "label")
    print(f"{d}/   {len(fs)} files  ({lab_n} by commit label)")
    for w in wrapped(fs):
        print(w)
    print()

ung = by_dir.get(None, [])
placed = len(LIB) - len(ung)
how = Counter(pl[f][1] for f in LIB if pl[f][0] is not None)
print(f"placed {placed} files in {len(CHARTER)} directories: "
      f"{how['label']} by commit label,\n{how['name']} by filename, "
      f"{how['override']} by override. The table is 14 lines.")
print(f"\n{len(ung)} files match no subsystem and are left ungrouped:")
for w in wrapped(ung):
    print(w)

# Second check with the call graph. It records which file uses
# functions from which other file, so it does not use names and is
# independent of the labels. Group files that call each other, then
# compare each group with the directories. Leave ungrouped helpers out.
agree = loose = 0
boundary = []
for c in clusters:
    by = defaultdict(list)
    for f in c:
        if pl[f][0]:
            by[pl[f][0]].append(f[:-2])
    dirs = sorted(by, key=lambda d: (-len(by[d]), d))
    if len(dirs) <= 1:
        agree += 1
    elif len(by[dirs[0]]) >= 2:
        boundary.append((dirs, by))
    else:
        loose += 1
boundary.sort(key=lambda x: (-len(x[1][x[0][0]]), x[0][0]))
covered = sum(len(c) for c in clusters)


def plural(n):
    return "group" if n == 1 else "groups"


print("\nSecond check. The call graph records which file uses "
      "functions from")
print("another file, so it does not use names. Group the files that "
      "call each")
print(f"other, then compare each group with the directories above. "
      f"{covered} files\nfall into {len(clusters)} call groups. The "
      f"{len(clusters)} sort into three kinds:")
print(f"  {agree} {plural(agree)}: all placed files land in one "
      "directory (call graph agrees).")
print(f"  {len(boundary)} {plural(len(boundary))}: most files in one "
      "directory, plus one or two\n     from another (the boundary "
      "cases to check).")
print(f"  {loose} {plural(loose)}: shared helpers that many files call "
      "(not shown).")
if boundary:
    print("\nBoundary groups, largest directory first. Each line "
          "is one")
    print("directory and its files in the group. Example: shallow "
          "is in")
    print("revision, but the call graph groups it with the transport "
          "files.")
    for dirs, by in boundary:
        print()
        for d in dirs:
            label = f"  {d} ({len(by[d])}): "
            for w in textwrap.wrap(" ".join(sorted(by[d])), 70,
                                   initial_indent=label,
                                   subsequent_indent=" " * len(label),
                                   break_long_words=False,
                                   break_on_hyphens=False):
                print(w)

# Label vs filename: the honest circularity check.
same, diff = 0, []
for f in sorted(LIB):
    lab = modal(f)
    if not lab:
        continue
    dl = to_dir(lab.split("/")[0].split("-")[0])
    dn = to_dir(f[:-2].split("-")[0])
    if dl and dn:
        if dl == dn:
            same += 1
        elif f not in OVERRIDE:
            diff.append((f, dl, dn, lab))
print(f"\nCommit label vs filename: both point to the same directory "
      f"for {same} files\nand disagree for {len(diff)} (the "
      f"{len(OVERRIDE)} overrides are separate). The commit\nlabel and "
      "the filename follow the same naming rule, so the "
      "label\nconfirms the naming, not the grouping. Only the call "
      "graph is an\nindependent check.")
for f, dl, dn, lab in diff:
    print(f"  {f[:-2]:20} label->{dl:10} name->{dn:10} ('{lab}:')")
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.