Skip to content

Commit 1aaf6fa

Browse files
authored
Blame: support builtin repository packages (and files) (spack#50877)
Fixes spack#50876 Without these changes, `spack blame <package>` fails. For example, with `builtin` configured with the `git` url: ``` $ spack blame lua fatal: '/Users/dahlgren1/.spack/package_repos/fncqgg4/repos/spack_repo/builtin/packages/lua/package.py' is outside repository at '/Users/dahlgren1/github/prs/spack' ==> Error: Command exited with status 128: '/usr/bin/git' 'blame' '--line-porcelain' '--ignore-revs-file' '$spack/.git-blame-ignore-revs' $HOME/.spack/package_repos/fncqgg4/repos/spack_repo/builtin/packages/lua/package.py' ``` and `builtin` configured for a user's local repository (e.g., a clone of `spack/spack-packages`): ``` $ spack blame lua fatal: '$spack-packages/repos/spack_repo/builtin/packages/lua/package.py' is outside repository at '$spack' ==> Error: Command exited with status 128: '/usr/bin/git' 'blame' '--line-porcelain' '--ignore-revs-file' '$spack/.git-blame-ignore-revs' '$spack-packages/repos/spack_repo/builtin/packages/lua/package.py' ``` With these changes, you get the expected output: ``` $ spack blame lua LAST_COMMIT LINES % AUTHOR EMAIL 3 weeks ago 5 2.0 Harmen Stoppels <[email protected]> a month ago 12 4.7 Adam J. Stewart <[email protected]> 5 months ago 51 20.1 Todd Gamblin <[email protected]> 7 months ago 21 8.3 Giuncan <[email protected]> 7 months ago 133 52.4 Tom Scogland <[email protected]> 8 months ago 2 0.8 Auriane R. <[email protected]> a year ago 2 0.8 John W. Parent <[email protected]> a year ago 11 4.3 Chris White <[email protected]> a year ago 1 0.4 Martin Aumüller <[email protected]> 2 years ago 3 1.2 Massimiliano Culpo <[email protected]> 3 years ago 1 0.4 Tamara Dahlgren <[email protected]> 3 years ago 1 0.4 Vanessasaurus <[email protected]> 5 years ago 9 3.5 Robert Pavel <[email protected]> 6 years ago 2 0.8 Stephen Herbein <[email protected]> 3 weeks ago 254 100.0 ``` And if the equivalent ignore commits are added to `spack/spack-packages` (spack/spack-packages#199), the `spack blame` output is a little different: ``` $ spack -d blame lua ... /.git-blame-ignore-revs' '--line-porcelain' '/Users/dahlgren1/github/spack-packages/repos/spack_repo/builtin/packages/lua/package.py' LAST_COMMIT LINES % AUTHOR EMAIL 3 weeks ago 5 2.0 Harmen Stoppels <[email protected]> a month ago 9 3.5 Adam J. Stewart <[email protected]> 5 months ago 20 7.9 Todd Gamblin <[email protected]> 7 months ago 21 8.3 Giuncan <[email protected]> 7 months ago 147 57.9 Tom Scogland <[email protected]> 8 months ago 2 0.8 Auriane R. <[email protected]> a year ago 2 0.8 John W. Parent <[email protected]> a year ago 11 4.3 Chris White <[email protected]> a year ago 1 0.4 Martin Aumüller <[email protected]> 2 years ago 4 1.6 Massimiliano Culpo <[email protected]> 3 years ago 3 1.2 Peter Brady <[email protected]> 3 years ago 1 0.4 Tamara Dahlgren <[email protected]> 3 years ago 2 0.8 Vanessasaurus <[email protected]> 4 years ago 24 9.4 Robert Pavel <[email protected]> 6 years ago 2 0.8 Stephen Herbein <[email protected]> 3 weeks ago 254 100.0 ``` The changes also allow `spack blame` to report on non-package files in spack repositories: ``` $ spack -d blame pyproject.toml ... ==> [2025-06-10-15:16:00.717914] '/usr/bin/git' 'blame' '--ignore-revs-file' '$pack-packages/.git-blame-ignore-revs' '--line-porcelain' '$spack-packages/pyproject.toml' LAST_COMMIT LINES % AUTHOR EMAIL 2 weeks ago 242 100.0 Ryan Krattiger <[email protected]> 2 weeks ago 242 100.0 ``` --- Signed-off-by: Todd Gamblin <[email protected]>
1 parent c702f5b commit 1aaf6fa

File tree

2 files changed

+329
-30
lines changed

2 files changed

+329
-30
lines changed

lib/spack/spack/cmd/blame.py

Lines changed: 160 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,29 @@
44

55
import argparse
66
import os
7+
import pathlib
78
import re
89
import sys
10+
from typing import Optional, Union
911

1012
import llnl.util.tty as tty
1113
from llnl.util.filesystem import working_dir
1214
from llnl.util.lang import pretty_date
1315
from llnl.util.tty.colify import colify_table
1416

15-
import spack.paths
17+
import spack.config
1618
import spack.repo
1719
import spack.util.git
1820
import spack.util.spack_json as sjson
1921
from spack.cmd import spack_is_git_repo
22+
from spack.util.executable import ProcessError
2023

2124
description = "show contributors to packages"
2225
section = "developer"
2326
level = "long"
2427

28+
git = spack.util.git.git(required=True)
29+
2530

2631
def setup_parser(subparser: argparse.ArgumentParser) -> None:
2732
view_group = subparser.add_mutually_exclusive_group()
@@ -112,41 +117,169 @@ def dump_json(rows, last_mod, total_lines, emails):
112117
sjson.dump(result, sys.stdout)
113118

114119

120+
def git_prefix(path: Union[str, pathlib.Path]) -> Optional[pathlib.Path]:
121+
"""Return the top level directory if path is under a git repository.
122+
123+
Args:
124+
path: path of the item presumably under a git repository
125+
126+
Returns: path to the root of the git repository
127+
"""
128+
if not os.path.exists(path):
129+
return None
130+
131+
work_dir = path if os.path.isdir(path) else os.path.dirname(path)
132+
with working_dir(work_dir):
133+
try:
134+
result = git("rev-parse", "--show-toplevel", output=str, error=str)
135+
return pathlib.Path(result.split("\n")[0])
136+
except ProcessError:
137+
tty.die(f"'{path}' is not in a git repository.")
138+
139+
140+
def package_repo_root(path: Union[str, pathlib.Path]) -> Optional[pathlib.Path]:
141+
"""Find the appropriate package repository's git root directory.
142+
143+
Provides a warning for a remote package repository since there is a risk that
144+
the blame results are inaccurate.
145+
146+
Args:
147+
path: path to an arbitrary file presumably in one of the spack package repos
148+
149+
Returns: path to the package repository's git root directory or None
150+
"""
151+
descriptors = spack.repo.RepoDescriptors.from_config(
152+
lock=spack.repo.package_repository_lock(), config=spack.config.CONFIG
153+
)
154+
path = pathlib.Path(path)
155+
prefix: Optional[pathlib.Path] = None
156+
for _, desc in descriptors.items():
157+
# Handle the remote case, whose destination is by definition the git root
158+
if hasattr(desc, "destination"):
159+
repo_dest = pathlib.Path(desc.destination)
160+
if (repo_dest / ".git").exists():
161+
prefix = repo_dest
162+
163+
# TODO: replace check with `is_relative_to` once supported
164+
if prefix and str(path).startswith(str(prefix)):
165+
return prefix
166+
167+
# Handle the local repository case, making sure it's a spack repository.
168+
if hasattr(desc, "path"):
169+
repo_path = pathlib.Path(desc.path)
170+
if "spack_repo" in repo_path.parts:
171+
prefix = git_prefix(repo_path)
172+
173+
# TODO: replace check with `is_relative_to` once supported
174+
if prefix and str(path).startswith(str(prefix)):
175+
return prefix
176+
177+
return None
178+
179+
180+
def git_supports_unshallow() -> bool:
181+
output = git("fetch", "--help", output=str, error=str)
182+
return "--unshallow" in output
183+
184+
185+
def ensure_full_history(prefix: str, path: str) -> None:
186+
"""Ensure the git repository at the prefix has its full history.
187+
188+
Args:
189+
prefix: the root directory of the git repository
190+
path: the package or file name under consideration (for messages)
191+
"""
192+
assert os.path.isdir(prefix)
193+
194+
with working_dir(prefix):
195+
shallow_dir = os.path.join(prefix, ".git", "shallow")
196+
if os.path.isdir(shallow_dir):
197+
if git_supports_unshallow():
198+
try:
199+
# Capture the error output (e.g., irrelevant for full repo)
200+
# to ensure the output is clean.
201+
git("fetch", "--unshallow", error=str)
202+
except ProcessError as e:
203+
tty.die(
204+
f"Cannot report blame for {path}.\n"
205+
"Unable to retrieve the full git history for "
206+
f'{prefix} due to "{str(e)}" error.'
207+
)
208+
else:
209+
tty.die(
210+
f"Cannot report blame for {path}.\n"
211+
f"Unable to retrieve the full git history for {prefix}. "
212+
"Use a newer 'git' that supports 'git fetch --unshallow'."
213+
)
214+
215+
115216
def blame(parser, args):
116217
# make sure this is a git repo
117218
if not spack_is_git_repo():
118-
tty.die("This spack is not a git clone. Can't use 'spack blame'")
119-
git = spack.util.git.git(required=True)
219+
tty.die("This spack is not a git clone. You cannot use 'spack blame'.")
120220

121-
# Get name of file to blame
221+
# Get the name of the path to blame and its repository prefix
222+
# so we can honor any .git-blame-ignore-revs that may be present.
122223
blame_file = None
123-
if os.path.isfile(args.package_or_file):
124-
path = os.path.realpath(args.package_or_file)
125-
if path.startswith(spack.paths.prefix):
126-
blame_file = path
224+
prefix = None
225+
if os.path.exists(args.package_or_file):
226+
blame_file = os.path.realpath(args.package_or_file)
227+
prefix = package_repo_root(blame_file)
127228

229+
# Get path to what we assume is a package (including to a cached version
230+
# of a remote package repository.)
128231
if not blame_file:
129-
pkg_cls = spack.repo.PATH.get_pkg_class(args.package_or_file)
130-
blame_file = pkg_cls.module.__file__.rstrip("c") # .pyc -> .py
232+
try:
233+
blame_file = spack.repo.PATH.filename_for_package_name(args.package_or_file)
234+
except spack.repo.UnknownNamespaceError:
235+
# the argument is not a package (or does not exist)
236+
pass
237+
238+
if blame_file and os.path.isfile(blame_file):
239+
prefix = package_repo_root(blame_file)
240+
241+
if not blame_file or not os.path.exists(blame_file):
242+
tty.die(f"'{args.package_or_file}' does not exist.")
243+
244+
if prefix is None:
245+
tty.msg(f"'{args.package_or_file}' is not within a spack package repository")
246+
247+
path_prefix = git_prefix(blame_file)
248+
if path_prefix != prefix:
249+
# You are attempting to get 'blame' for a path outside of a configured
250+
# package repository (e.g., within a spack/spack clone). We'll use the
251+
# path's prefix instead to ensure working under the proper git
252+
# repository.
253+
prefix = path_prefix
254+
255+
# Make sure we can get the full/known blame even when the repository
256+
# is remote.
257+
ensure_full_history(prefix, args.package_or_file)
258+
259+
# Get blame information for the path EVEN when it is located in a different
260+
# spack repository (e.g., spack/spack-packages) or a different git
261+
# repository.
262+
with working_dir(prefix):
263+
# Now we can get the blame results.
264+
options = ["blame"]
131265

132-
# get git blame for the package
133-
with working_dir(spack.paths.prefix):
134266
# ignore the great black reformatting of 2022
135-
ignore_file = os.path.join(spack.paths.prefix, ".git-blame-ignore-revs")
136-
137-
if args.view == "git":
138-
git("blame", "--ignore-revs-file", ignore_file, blame_file)
139-
return
140-
else:
141-
output = git(
142-
"blame",
143-
"--line-porcelain",
144-
"--ignore-revs-file",
145-
ignore_file,
146-
blame_file,
147-
output=str,
148-
)
149-
lines = output.split("\n")
267+
ignore_file = prefix / ".git-blame-ignore-revs"
268+
if ignore_file.exists():
269+
options.extend(["--ignore-revs-file", str(ignore_file)])
270+
271+
try:
272+
if args.view == "git":
273+
options.append(str(blame_file))
274+
git(*options)
275+
return
276+
else:
277+
options.extend(["--line-porcelain", str(blame_file)])
278+
output = git(*options, output=str, error=str)
279+
lines = output.split("\n")
280+
except ProcessError as err:
281+
# e.g., blame information is not tracked if the path is a directory
282+
tty.die(f"Blame information is not tracked for '{blame_file}':\n{err.long_message}")
150283

151284
# Histogram authors
152285
counts = {}

0 commit comments

Comments
 (0)