Skip to content

repo_on_fire.workspace

Workspace Utilities.

Workspace

Interact with a repo workspace.

This class provides some high level utilities that can be used to efficiently modify a repo workspace. It does not reinvent the wheel but instead delegates a lot of work to repo, encapsulating stuff here and there to make things easy.

Source code in repo_on_fire/workspace.py
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
class Workspace:
    """Interact with a repo workspace.

    This class provides some high level utilities that can be used to efficiently
    modify a repo workspace. It does not reinvent the wheel but instead delegates
    a lot of work to repo, encapsulating stuff here and there to make things easy.
    """

    def __init__(self, repo: Repo):
        """Initialize a new Workspace instance.

        Args:
            repo: The Repo instance used by the workspace.
        """
        self._repo = repo

    def switch(self, branch: str):
        """Switch a workspace to a specific branch.

        This method will try to switch the workspace to a particular branch.
        First, it calls `repo sync -d` to bring the workspace up to date and
        switch away from any already checked out branch. Then, it will use
        a `repo forall` command which tries to check out the given branch in any
        project in the workspace.

        If checking out the branch fails for all projects, the method raises
        a RepoOnFireException exception.

        Args:
            branch: The branch to checkout.

        Raises:
            RepoOnFireException: In case either the sync fails or the desired
                branch cannot be checked out in any repository.
        """
        result = self._repo.call(["sync", "-d"])
        if result != 0:
            raise RepoOnFireException("Failed to detach workspace to defaults")
        with TemporaryDirectory() as temp_dir:
            temp_path = Path(temp_dir)
            stamp_file = temp_path / "success.txt"
            inner_args = [
                sys.executable,
                str(HELPER_SCRIPT_PATH),
                "checkout",
                branch,
                str(stamp_file),
            ]
            result = self._repo.call(["forall", "-c", shlex.join(inner_args)])
            if result != 0:
                raise RepoOnFireException(f"Failed to checkout branch {branch} in workspace")
            if not stamp_file.exists():
                raise RepoOnFireException(f"Failed to checkout {branch} in any project")

    def snapshot(self, output: Optional[Path] = None, cwd: Optional[Path] = None) -> str:
        """Create a frozen snapshot manifest for the workspace."""
        if cwd is None:
            cwd = Path(os.getcwd())
        repo_root = Git.find_repo_root(cwd)
        manifest_text = self._repo.output(
            [
                "manifest",
                "-r",
                "--suppress-upstream-revision",
                "--suppress-dest-branch",
            ],
            cwd=repo_root,
        )
        snapshot_root = ET.fromstring(manifest_text)
        source_manifest = self._load_source_manifest(repo_root)
        self._add_excluded_projects(snapshot_root, source_manifest, repo_root)
        xml_text = self._serialize_manifest(snapshot_root)

        if output is not None:
            output.write_text(xml_text, encoding="utf-8")
        return xml_text

    def _add_excluded_projects(
        self, snapshot_root: ET.Element, source_manifest: _ManifestData, repo_root: Path
    ):
        included_project_keys = {
            self._project_key(project) for project in snapshot_root.findall("project")
        }

        for project in self._find_notdefault_projects(source_manifest.root):
            if self._project_key_from_values(project.name, project.path) in included_project_keys:
                continue

            revision = self._project_revision(project, source_manifest)
            if revision is None:
                self._append_omitted_project_comment(
                    snapshot_root, project, "no revision was configured"
                )
                continue

            if _HEX_REVISION_RE.match(revision):
                frozen_revision = revision
            else:
                repository_url = self._project_url(project, source_manifest, repo_root)
                try:
                    frozen_revision = self._resolve_remote_revision(repository_url, revision)
                except (RepoOnFireException, subprocess.CalledProcessError):
                    self._append_omitted_project_comment(
                        snapshot_root,
                        project,
                        f"git ls-remote failed for {repository_url} at {revision}",
                    )
                    continue

            snapshot_project = ET.Element(
                "project",
                {
                    key: value
                    for key, value in project.attributes.items()
                    if key not in {"groups", "upstream", "dest-branch"}
                },
            )
            snapshot_project.set("name", project.name)
            if project.path != project.name:
                snapshot_project.set("path", project.path)
            snapshot_project.set("revision", frozen_revision)
            snapshot_root.append(snapshot_project)

    def _load_source_manifest(self, repo_root: Path) -> _ManifestData:
        manifest_path = repo_root / ".repo" / "manifest.xml"
        if not manifest_path.exists():
            raise RepoOnFireException(f"Could not find manifest at {manifest_path}")

        manifest_tree = self._load_manifest_file(
            manifest_path.resolve(), [], repo_root / ".repo" / "manifests"
        )
        root = manifest_tree.getroot()
        default = root.find("default")
        remotes = {
            remote.attrib["name"]: _ManifestRemote(
                fetch=remote.attrib["fetch"], revision=remote.attrib.get("revision")
            )
            for remote in root.findall("remote")
            if "name" in remote.attrib and "fetch" in remote.attrib
        }
        return _ManifestData(
            root=root,
            remotes=remotes,
            default_remote=default.attrib.get("remote") if default is not None else None,
            default_revision=default.attrib.get("revision") if default is not None else None,
        )

    def _load_manifest_file(
        self, manifest_path: Path, seen: List[Path], include_base_path: Path
    ) -> ET.ElementTree:
        if manifest_path in seen:
            raise RepoOnFireException(f"Recursive manifest include detected at {manifest_path}")
        tree = ET.parse(manifest_path)
        root = tree.getroot()
        for include in list(root.findall("include")):
            include_name = include.attrib.get("name")
            if include_name is None:
                continue
            include_tree = self._load_manifest_file(
                include_base_path / include_name,
                [*seen, manifest_path],
                include_base_path,
            )
            include_index = list(root).index(include)
            root.remove(include)
            for child in list(include_tree.getroot()):
                root.insert(include_index, child)
                include_index += 1
        return tree

    @staticmethod
    def _find_notdefault_projects(root: ET.Element) -> List[_ManifestProject]:
        projects = []
        for project in root.findall("project"):
            groups = [
                group.strip()
                for group in project.attrib.get("groups", "").split(",")
                if group.strip()
            ]
            if "notdefault" not in groups:
                continue
            name = project.attrib.get("name")
            if name is None:
                continue
            path = project.attrib.get("path", name)
            projects.append(
                _ManifestProject(
                    name=name,
                    path=path,
                    remote_name=project.attrib.get("remote"),
                    revision=project.attrib.get("revision"),
                    groups=groups,
                    attributes=dict(project.attrib),
                )
            )
        return projects

    def _project_url(
        self, project: _ManifestProject, manifest: _ManifestData, repo_root: Path
    ) -> str:
        remote_name = project.remote_name or manifest.default_remote
        if remote_name is None or remote_name not in manifest.remotes:
            raise RepoOnFireException(f"Could not determine remote for project {project.name}")

        fetch = manifest.remotes[remote_name]
        if self._is_absolute_git_url(fetch.fetch):
            return self._join_git_url(fetch.fetch, project.name)

        manifest_url = self._manifest_repository_url(repo_root)
        if manifest_url is None:
            raise RepoOnFireException(f"Could not resolve relative remote {fetch.fetch}")
        remote_url = urljoin(manifest_url.rstrip("/") + "/", fetch.fetch)
        return self._join_git_url(remote_url, project.name)

    @staticmethod
    def _project_revision(project: _ManifestProject, manifest: _ManifestData) -> Optional[str]:
        if project.revision is not None:
            return project.revision

        remote_name = project.remote_name or manifest.default_remote
        if remote_name is not None and remote_name in manifest.remotes:
            remote_revision = manifest.remotes[remote_name].revision
            if remote_revision is not None:
                return remote_revision

        return manifest.default_revision

    def _resolve_remote_revision(self, repository_url: str, revision: str) -> str:
        ref = revision
        if not revision.startswith("refs/"):
            ref = f"refs/heads/{revision}"
        output = self._repo.ls_remote(repository_url, ref)
        for line in output.splitlines():
            parts = line.split()
            if len(parts) > _LS_REMOTE_REF_INDEX and parts[_LS_REMOTE_REF_INDEX] == ref:
                return parts[0]
        raise RepoOnFireException(f"Could not resolve {ref} in {repository_url}")

    @staticmethod
    def _manifest_repository_url(repo_root: Path) -> Optional[str]:
        config_path = repo_root / ".repo" / "manifests.git" / "config"
        if not config_path.exists():
            return None
        config = config_path.read_text(encoding="utf-8")
        in_origin = False
        for line in config.splitlines():
            stripped = line.strip()
            if stripped.startswith("["):
                in_origin = stripped == '[remote "origin"]'
                continue
            if in_origin and stripped.startswith("url ="):
                return stripped.split("=", 1)[1].strip()
        return None

    @staticmethod
    def _append_omitted_project_comment(
        snapshot_root: ET.Element, project: _ManifestProject, reason: str
    ):
        snapshot_root.append(
            ET.Comment(
                f" repo-on-fire: omitted project {project.name!r} "
                f"at {project.path!r}; {reason} "
            )
        )

    @staticmethod
    def _project_key(project: ET.Element) -> str:
        name = project.attrib.get("name", "")
        return Workspace._project_key_from_values(name, project.attrib.get("path", name))

    @staticmethod
    def _project_key_from_values(name: str, path: str) -> str:
        return f"{name}\0{path}"

    @staticmethod
    def _is_absolute_git_url(url: str) -> bool:
        parsed = urlparse(url)
        return bool(parsed.scheme) or "@" in url or url.startswith("/")

    @staticmethod
    def _join_git_url(base_url: str, project_name: str) -> str:
        if base_url.endswith(":") and "://" not in base_url:
            return f"{base_url}{project_name}"
        if "://" not in base_url and ":" in base_url and not base_url.startswith("/"):
            return f"{base_url.rstrip('/')}/{project_name}"
        return posixpath.join(base_url.rstrip("/"), project_name)

    @staticmethod
    def _serialize_manifest(root: ET.Element) -> str:
        Workspace._indent_xml(root)
        return ET.tostring(root, encoding="unicode") + "\n"

    @staticmethod
    def _indent_xml(element: ET.Element, level: int = 0):
        indent = "\n" + level * "  "
        child_indent = "\n" + (level + 1) * "  "
        children = list(element)
        if children:
            if not element.text or not element.text.strip():
                element.text = child_indent
            for child in children:
                Workspace._indent_xml(child, level + 1)
            if not children[-1].tail or not children[-1].tail.strip():
                children[-1].tail = indent
        if level and (not element.tail or not element.tail.strip()):
            element.tail = indent

__init__(repo)

Initialize a new Workspace instance.

Parameters:

Name Type Description Default
repo Repo

The Repo instance used by the workspace.

required
Source code in repo_on_fire/workspace.py
57
58
59
60
61
62
63
def __init__(self, repo: Repo):
    """Initialize a new Workspace instance.

    Args:
        repo: The Repo instance used by the workspace.
    """
    self._repo = repo

snapshot(output=None, cwd=None)

Create a frozen snapshot manifest for the workspace.

Source code in repo_on_fire/workspace.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
def snapshot(self, output: Optional[Path] = None, cwd: Optional[Path] = None) -> str:
    """Create a frozen snapshot manifest for the workspace."""
    if cwd is None:
        cwd = Path(os.getcwd())
    repo_root = Git.find_repo_root(cwd)
    manifest_text = self._repo.output(
        [
            "manifest",
            "-r",
            "--suppress-upstream-revision",
            "--suppress-dest-branch",
        ],
        cwd=repo_root,
    )
    snapshot_root = ET.fromstring(manifest_text)
    source_manifest = self._load_source_manifest(repo_root)
    self._add_excluded_projects(snapshot_root, source_manifest, repo_root)
    xml_text = self._serialize_manifest(snapshot_root)

    if output is not None:
        output.write_text(xml_text, encoding="utf-8")
    return xml_text

switch(branch)

Switch a workspace to a specific branch.

This method will try to switch the workspace to a particular branch. First, it calls repo sync -d to bring the workspace up to date and switch away from any already checked out branch. Then, it will use a repo forall command which tries to check out the given branch in any project in the workspace.

If checking out the branch fails for all projects, the method raises a RepoOnFireException exception.

Parameters:

Name Type Description Default
branch str

The branch to checkout.

required

Raises:

Type Description
RepoOnFireException

In case either the sync fails or the desired branch cannot be checked out in any repository.

Source code in repo_on_fire/workspace.py
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
def switch(self, branch: str):
    """Switch a workspace to a specific branch.

    This method will try to switch the workspace to a particular branch.
    First, it calls `repo sync -d` to bring the workspace up to date and
    switch away from any already checked out branch. Then, it will use
    a `repo forall` command which tries to check out the given branch in any
    project in the workspace.

    If checking out the branch fails for all projects, the method raises
    a RepoOnFireException exception.

    Args:
        branch: The branch to checkout.

    Raises:
        RepoOnFireException: In case either the sync fails or the desired
            branch cannot be checked out in any repository.
    """
    result = self._repo.call(["sync", "-d"])
    if result != 0:
        raise RepoOnFireException("Failed to detach workspace to defaults")
    with TemporaryDirectory() as temp_dir:
        temp_path = Path(temp_dir)
        stamp_file = temp_path / "success.txt"
        inner_args = [
            sys.executable,
            str(HELPER_SCRIPT_PATH),
            "checkout",
            branch,
            str(stamp_file),
        ]
        result = self._repo.call(["forall", "-c", shlex.join(inner_args)])
        if result != 0:
            raise RepoOnFireException(f"Failed to checkout branch {branch} in workspace")
        if not stamp_file.exists():
            raise RepoOnFireException(f"Failed to checkout {branch} in any project")