1 # Copyright 2020 The Pigweed Authors
3 # Licensed under the Apache License, Version 2.0 (the "License"); you may not
4 # use this file except in compliance with the License. You may obtain a copy of
7 # https://www.apache.org/licenses/LICENSE-2.0
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12 # License for the specific language governing permissions and limitations under
14 """Helpful commands for working with a Git repository."""
17 from pathlib import Path
19 from typing import Collection, Iterable, Iterator, List, NamedTuple, Optional
20 from typing import Pattern, Set, Tuple, Union
22 from pw_presubmit.tools import log_run, plural
24 _LOG = logging.getLogger(__name__)
25 PathOrStr = Union[Path, str]
28 def git_stdout(*args: PathOrStr,
30 repo: PathOrStr = '.') -> str:
31 return log_run(['git', '-C', repo, *args],
32 stdout=subprocess.PIPE,
33 stderr=None if show_stderr else subprocess.DEVNULL,
34 check=True).stdout.decode().strip()
37 def _ls_files(args: Collection[PathOrStr], repo: Path) -> Iterable[Path]:
38 """Returns results of git ls-files as absolute paths."""
39 git_root = repo.resolve()
40 for file in git_stdout('ls-files', '--', *args, repo=repo).splitlines():
44 def _diff_names(commit: str, pathspecs: Collection[PathOrStr],
45 repo: Path) -> Iterable[Path]:
46 """Returns absolute paths of files changed since the specified commit."""
48 for file in git_stdout('diff',
54 repo=repo).splitlines():
58 def list_files(commit: Optional[str] = None,
59 pathspecs: Collection[PathOrStr] = (),
60 repo_path: Optional[Path] = None) -> List[Path]:
61 """Lists files with git ls-files or git diff --name-only.
64 commit: commit to use as a base for git diff
65 pathspecs: Git pathspecs to use in git ls-files or diff
66 repo_path: repo path from which to run commands; defaults to Path.cwd()
69 A sorted list of absolute paths
72 repo_path = Path.cwd()
75 return sorted(_diff_names(commit, pathspecs, repo_path))
77 return sorted(_ls_files(pathspecs, repo_path))
80 def has_uncommitted_changes(repo: Optional[Path] = None) -> bool:
81 """Returns True if the Git repo has uncommitted changes in it.
83 This does not check for untracked files.
88 # Refresh the Git index so that the diff-index command will be accurate.
89 log_run(['git', '-C', repo, 'update-index', '-q', '--refresh'], check=True)
91 # diff-index exits with 1 if there are uncommitted changes.
92 return log_run(['git', '-C', repo, 'diff-index', '--quiet', 'HEAD',
93 '--']).returncode == 1
96 def _describe_constraints(git_root: Path, repo_path: Path,
97 commit: Optional[str],
98 pathspecs: Collection[PathOrStr],
99 exclude: Collection[Pattern[str]]) -> Iterable[str]:
100 if not git_root.samefile(repo_path):
102 f'under the {repo_path.resolve().relative_to(git_root.resolve())} '
106 yield f'that have changed since {commit}'
109 paths_str = ', '.join(str(p) for p in pathspecs)
110 yield f'that match {plural(pathspecs, "pathspec")} ({paths_str})'
113 yield (f'that do not match {plural(exclude, "pattern")} (' +
114 ', '.join(p.pattern for p in exclude) + ')')
117 def describe_files(git_root: Path, repo_path: Path, commit: Optional[str],
118 pathspecs: Collection[PathOrStr],
119 exclude: Collection[Pattern]) -> str:
120 """Completes 'Doing something to ...' for a set of files in a Git repo."""
122 _describe_constraints(git_root, repo_path, commit, pathspecs, exclude))
124 return f'all files in the {git_root.name} repo'
126 msg = f'files in the {git_root.name} repo'
127 if len(constraints) == 1:
128 return f'{msg} {constraints[0]}'
130 return msg + ''.join(f'\n - {line}' for line in constraints)
133 def root(repo_path: PathOrStr = '.', *, show_stderr: bool = True) -> Path:
134 """Returns the repository root as an absolute path.
137 FileNotFoundError: the path does not exist
138 subprocess.CalledProcessError: the path is not in a Git repo
140 repo_path = Path(repo_path)
141 if not repo_path.exists():
142 raise FileNotFoundError(f'{repo_path} does not exist')
145 git_stdout('rev-parse',
147 repo=repo_path if repo_path.is_dir() else repo_path.parent,
148 show_stderr=show_stderr))
151 def within_repo(repo_path: PathOrStr = '.') -> Optional[Path]:
152 """Similar to root(repo_path), returns None if the path is not in a repo."""
154 return root(repo_path, show_stderr=False)
155 except subprocess.CalledProcessError:
159 def is_repo(repo_path: PathOrStr = '.') -> bool:
160 """True if the path is tracked by a Git repo."""
161 return within_repo(repo_path) is not None
164 def path(repo_path: PathOrStr,
165 *additional_repo_paths: PathOrStr,
166 repo: PathOrStr = '.') -> Path:
167 """Returns a path relative to a Git repository's root."""
168 return root(repo).joinpath(repo_path, *additional_repo_paths)
171 class PythonPackage(NamedTuple):
172 root: Path # Path to the file containing the setup.py
173 package: Path # Path to the main package directory
174 packaged_files: Tuple[Path, ...] # All sources in the main package dir
175 other_files: Tuple[Path, ...] # Other Python files under root
177 def all_files(self) -> Tuple[Path, ...]:
178 return self.packaged_files + self.other_files
181 def all_python_packages(repo: PathOrStr = '.') -> Iterator[PythonPackage]:
182 """Finds all Python packages in the repo based on setup.py locations."""
185 for file in _ls_files(['setup.py', '*/setup.py'], Path(repo))
188 for py_dir in root_py_dirs:
189 all_packaged_files = _ls_files([py_dir / '*' / '*.py'], repo=py_dir)
190 common_dir: Optional[str] = None
192 # Make there is only one package directory with Python files in it.
193 for file in all_packaged_files:
194 package_dir = file.relative_to(py_dir).parts[0]
196 if common_dir is None:
197 common_dir = package_dir
198 elif common_dir != package_dir:
200 'There are multiple Python package directories in %s: %s '
201 'and %s. This is not supported by pw presubmit. Each '
202 'setup.py should correspond with a single Python package',
203 py_dir, common_dir, package_dir)
206 if common_dir is not None:
207 packaged_files = tuple(_ls_files(['*/*.py'], repo=py_dir))
209 f for f in _ls_files(['*.py'], repo=py_dir)
210 if f.name != 'setup.py' and f not in packaged_files)
212 yield PythonPackage(py_dir, py_dir / common_dir, packaged_files,
216 def python_packages_containing(
217 python_paths: Iterable[Path],
218 repo: PathOrStr = '.') -> Tuple[List[PythonPackage], List[Path]]:
219 """Finds all Python packages containing the provided Python paths.
222 ([packages], [files_not_in_packages])
224 all_packages = list(all_python_packages(repo))
226 packages: Set[PythonPackage] = set()
227 files_not_in_packages: List[Path] = []
229 for python_path in python_paths:
230 for package in all_packages:
231 if package.root in python_path.parents:
232 packages.add(package)
235 files_not_in_packages.append(python_path)
237 return list(packages), files_not_in_packages
240 def commit_message(commit: str = 'HEAD', repo: PathOrStr = '.') -> str:
241 return git_stdout('log', '--format=%B', '-n1', commit, repo=repo)