1#!/usr/bin/env python3
2# Copyright (C) 2023 The Android Open Source Project
3#
4# Licensed under the Apache License, Version 2.0 (the "License");
5# you may not use this file except in compliance with the License.
6# You may obtain a copy of the License at
7#
8#      http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15import argparse
16import glob
17import os
18import subprocess
19from pathlib import Path
20import textwrap
21from typing import Generator, Iterator
22
23from util import get_out_dir
24from util import get_top_dir
25
26
27def is_git_repo(p: Path) -> bool:
28    """checks if p is in a directory that's under git version control"""
29    git = subprocess.run(
30        args=f"git remote".split(),
31        cwd=p,
32        stdout=subprocess.DEVNULL,
33        stderr=subprocess.DEVNULL,
34    )
35    return git.returncode == 0
36
37
38def confirm(root_dir: Path, *globs: str):
39    for g in globs:
40        disallowed = g.startswith("!")
41        if disallowed:
42            g = g[1:]
43        paths = glob.iglob(g, root_dir=root_dir, recursive=True)
44        path = next(paths, None)
45        if disallowed:
46            if path is not None:
47                raise RuntimeError(f"{root_dir}/{path} unexpected")
48        else:
49            if path is None:
50                raise RuntimeError(f"{root_dir}/{g} doesn't match")
51
52
53def _should_visit(c: os.DirEntry) -> bool:
54    return c.is_dir() and not (
55        c.is_symlink()
56        or "." in c.name
57        or "test" in c.name
58        or Path(c.path) == get_out_dir()
59    )
60
61
62def find_matches(root_dir: Path, *globs: str) -> Generator[Path, None, None]:
63    """
64    Finds sub-paths satisfying the patterns
65    :param root_dir the first directory to start searching from
66    :param globs glob patterns to require or disallow (if starting with "!")
67    :returns dirs satisfying the glbos
68    """
69    bfs: list[Path] = [root_dir]
70    while len(bfs) > 0:
71        first = bfs.pop(0)
72        if is_git_repo(first):
73            try:
74                confirm(first, *globs)
75                yield first
76            except RuntimeError:
77                pass
78        children = [Path(c.path) for c in os.scandir(first) if _should_visit(c)]
79        children.sort()
80        bfs.extend(children)
81
82
83def main():
84    p = argparse.ArgumentParser(
85        formatter_class=argparse.RawTextHelpFormatter,
86        description=textwrap.dedent(
87            f"""\
88            A utility to find a directory that have descendants that satisfy
89            specified required glob patterns and have no descendent that
90            contradict any specified disallowed glob pattern.
91
92            Example:
93                {Path(__file__).name} '**/Android.bp'
94                {Path(__file__).name} '!**/BUILD' '**/Android.bp'
95
96            Don't forget to SINGLE QUOTE patterns to avoid shell glob expansion!
97            """
98        ),
99    )
100    p.add_argument(
101        "globs",
102        nargs="+",
103        help="""glob patterns to require or disallow(if preceded with "!")""",
104    )
105    p.add_argument(
106        "--root_dir",
107        "-r",
108        type=lambda s: Path(s).resolve(),
109        default=get_top_dir(),
110        help=textwrap.dedent(
111            """\
112                top dir to interpret the glob patterns relative to
113                defaults to %(default)s
114            """
115        ),
116    )
117    p.add_argument(
118        "--max",
119        "-m",
120        type=int,
121        default=1,
122        help=textwrap.dedent(
123            """\
124                maximum number of matching directories to show
125                defaults to %(default)s
126            """
127        ),
128    )
129    options = p.parse_args()
130    results = find_matches(options.root_dir, *options.globs)
131    max: int = options.max
132    while max > 0:
133        max -= 1
134        path = next(results, None)
135        if path is None:
136            break
137        print(f"{path.relative_to(get_top_dir())}")
138
139
140if __name__ == "__main__":
141    main()
142