blob: eedd31b4873590a8b082f7320001a37568873fea [file] [edit]
# Copyright 2025 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import base64
import fcntl
import json
import logging
import os
import shutil
import subprocess
import urllib.request
from contextlib import contextmanager
from datetime import datetime, timedelta
from functools import cache, cached_property
from pathlib import Path
from typing import (
Any,
Callable,
Concatenate,
Generator,
Literal,
ParamSpec,
Protocol,
TextIO,
TypeVar,
)
import cartfs
import logger as logger
import snapshotter
from util import sanitize_filename
class WorkspaceError(Exception):
"""Base exception for Cartfs errors."""
class RepoSetupError(WorkspaceError):
"""Raised when there is an error setting up the repository."""
class NotInCogWorkspaceError(WorkspaceError):
"""Raised when the current directory is not within a Cog workspace."""
CARTFS_SYMLINK_NAME: str = "cartfs-dir"
COG_METADATA_FILE_NAME: str = ".cog.json"
class CogMetadata:
"""Represents the metadata stored in the .cog.json file."""
def __init__(
self,
workspace_name: str,
repo_name: str,
workspace_id: str | None = None,
):
"""Initializes CogMetadata.
Args:
workspace_name: The name of the cog workspace.
repo_name: The name of the repository within the workspace.
workspace_id: The unique ID for the workspace.
"""
self.workspace_name = workspace_name
self.repo_name = repo_name
self.workspace_id = workspace_id
def to_dict(self) -> dict[str, str | None]:
"""Returns a dictionary representation of the metadata."""
return {
"workspace_name": self.workspace_name,
"repo_name": self.repo_name,
"workspace_id": self.workspace_id,
}
@classmethod
def from_file(cls, path: Path) -> "CogMetadata | None":
"""Loads metadata from a .cog.json file.
Args:
path: The full path to the .cog.json file.
Returns:
A CogMetadata instance if the file is valid, otherwise None.
"""
if not path.exists():
return None
try:
data = json.loads(path.read_text())
return cls(
workspace_name=data["workspace_name"],
repo_name=data["repo_name"],
workspace_id=data.get("workspace_id"),
)
except (
OSError,
json.JSONDecodeError,
KeyError,
) as e:
logger.log_warn(f"Warning: Could not read or parse {path}: {e}")
return None
def write(self, directory: Path) -> None:
"""Writes the metadata to a JSON file in the given directory."""
path = directory / COG_METADATA_FILE_NAME
path.write_text(json.dumps(self.to_dict(), indent=4))
class HasWorkspace(Protocol):
workspace: "Workspace"
T = TypeVar("T", bound=HasWorkspace)
P = ParamSpec("P")
R = TypeVar("R")
def lock(
func: Callable[Concatenate[T, P], R]
) -> Callable[Concatenate[T, P], R]:
"""Wraps a method with `self.workspace.lock()`.
Note: The decorated method must be called on an object that has a
`self.workspace` attribute.
"""
def lock_and_call(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R:
with self.workspace.lock():
return func(self, *args, **kwargs)
return lock_and_call
class Workspace:
"""A class to encapsulate a Cog workspace and an associated Cartfs workspace."""
@staticmethod
@cache
def cogd_path() -> Path:
try:
return Path(
subprocess.check_output(
["git-citc", "cogd"],
text=True,
).strip()
)
except (FileNotFoundError, subprocess.CalledProcessError) as e:
raise NotInCogWorkspaceError(
"Unable to find the cog workspace. Are you in a cog workspace?"
) from e
def __init__(
self,
repo_dir: Path | None = None,
) -> None:
"""Initializes a Workspace instance."""
self.repo_dir = repo_dir or Workspace.cogd_path()
self.cartfs_instance = cartfs.Cartfs()
self.cartfs_mount_point = self.cartfs_instance.mount_point
self._is_cartfs_workspace_clean = False
self._lock_file_handle: TextIO | None = None
self._lock_count = 0
logger.setup_file_logging(self.workspace_root)
@property
def workspace_root(self) -> Path:
return self.repo_dir.parent
@cached_property
def workspace_id(self) -> str:
return (
(self.workspace_root / ".citc" / "workspace_id").read_text().strip()
)
@property
def workspace_name(self) -> str:
return self.workspace_root.name
@property
def repo_name(self) -> str:
return self.repo_dir.name
@cached_property
def _golden_snapshot_dir(self) -> Path | None:
"""Returns the golden snapshot directory, if it exists and is valid.
Returns:
A path to the resolved golden snapshot directory relative to the cartfs mount point if
found and valid, otherwise None.
"""
# Golden snapshots are not supported for fuchsia/fuchsia.
if self.repo_dir.name == "fuchsia":
return None
# `.fuchsia_golden_snapshot` is a symlink pointing to the currently active golden snapshot.
result = (
self.cartfs_mount_point / ".fuchsia_golden_snapshot"
).resolve()
# Check that the golden snapshot is not corrupted.
if not (result / ".integration_commit_hash").is_file():
return None
# The golden snapshot must be found within the cartfs mount point.
try:
return result.relative_to(self.cartfs_mount_point)
except ValueError:
return None
@cached_property
def cartfs_dir(self) -> Path:
cartfs_dir = self._get_linked_cartfs_dir()
if not cartfs_dir:
raise RepoSetupError("No cartfs directory found.")
return cartfs_dir
@property
def has_cartfs_dir(self) -> bool:
try:
_ = self.cartfs_dir
return True
except RepoSetupError:
return False
@property
def cartfs_fuchsia_dir(self) -> Path:
return self.cartfs_dir / "fuchsia"
def _get_linked_cartfs_dir(self) -> Path | None:
"""Gets the linked cartfs directory for a specific repo in a cog workspace.
A workspace is considered linked if a symlink named `cartfs-dir` exists
inside the specified repository directory, pointing to a valid cartfs
directory. This target cartfs directory must contain a `.cog.json` file
with a matching `repo_name`, `workspace_name`, and `workspace_id`.
Returns:
The absolute path to the linked cartfs directory if found and valid,
otherwise None.
"""
symlink_path = self.repo_dir / CARTFS_SYMLINK_NAME
if not symlink_path.is_symlink():
return None
target_path = symlink_path.readlink()
if not target_path.is_absolute():
# Handles relative symlinks. The target is relative to the directory
# containing the symlink.
target_path = self.repo_dir / target_path
if not target_path.is_dir():
return None
metadata = CogMetadata.from_file(target_path / COG_METADATA_FILE_NAME)
if not metadata:
return None
if (
metadata.repo_name != self.repo_name
or metadata.workspace_name != self.workspace_name
or metadata.workspace_id != self.workspace_id
):
return None
return target_path
@cached_property
def lock_file(self) -> Path:
lock_dir = Path.home() / ".cache" / "cog"
lock_dir.mkdir(parents=True, exist_ok=True)
return lock_dir / sanitize_filename(
f"{self.workspace_name}-{self.workspace_id}.lock"
)
@contextmanager
def lock(self) -> Generator[None, None, None]:
"""Synchronously locks operations on this Workspace instance.
Nested lock attempts are no-ops. Not thread safe.
"""
# Lock is only acquired when entering the first `lock` context.
self._lock_count += 1
if self._lock_count == 1:
try:
# Acquire lock now, or wait until another process releases the lock.
self._lock_file_handle = open(self.lock_file, "a+")
try:
fcntl.flock(
self._lock_file_handle, fcntl.LOCK_EX | fcntl.LOCK_NB
)
except BlockingIOError:
try:
lock_owner_pid = self.lock_file.read_text().strip()
except Exception:
lock_owner_pid = None
logger.log_warn(
f"Waiting for another process (PID: {lock_owner_pid or 'unknown'}) "
"to finish working on this workspace..."
)
fcntl.flock(self._lock_file_handle, fcntl.LOCK_EX)
# Update lock file with current process ID.
self._lock_file_handle.seek(0)
self._lock_file_handle.truncate()
self._lock_file_handle.write(str(os.getpid()))
self._lock_file_handle.flush()
logger.log_debug(
f"Acquired lock for workspace: {self.lock_file}"
)
except BaseException:
logger.log_error("Could not acquire lock for workspace.")
if self._lock_file_handle:
self._lock_file_handle.close()
self._lock_file_handle = None
self._lock_count -= 1
raise
try:
# Lock acquired, continue execution.
yield
finally:
# Lock is only released when exiting the last `lock` context.
self._lock_count -= 1
if self._lock_count == 0:
assert self._lock_file_handle
self._lock_file_handle.close()
self._lock_file_handle = None
logger.log_debug(
f"Released lock for workspace: {self.lock_file}"
)
def _assert_locked(self) -> None:
"""Asserts that the workspace lock is held by the invoker via `lock`.
This assertion should be enforced by any method that needs to ensure that this
workspace is not being actively modified by another `//scripts/cog` process.
"""
if self._lock_count == 0:
raise WorkspaceError(
"Please acquire a workspace lock before calling this method."
)
@cached_property
def config(self) -> dict[str, Any]:
repo_config_path = (
self.repo_dir / "scripts" / "cog" / "repo_config.json"
)
if not repo_config_path.exists():
raise FileNotFoundError(
f"Repo config not found at {repo_config_path}"
)
return json.loads(repo_config_path.read_text())
def init_cartfs_workspace(self, snapshot: bool = True) -> None:
"""Initializes the cartfs workspace.
Args:
snapshot: Whether to snapshot the cartfs workspace from a previous instance.
"""
self._assert_locked()
if snapshot:
logger.emit_status("Attempting to snapshot CartFS workspace...")
self._init_cartfs_workspace_snapshot()
# If snapshotting failed or was disabled, initialize an empty workspace.
if not self.has_cartfs_dir:
logger.emit_status("Creating an empty CartFS workspace...")
self._init_cartfs_workspace_empty()
self._is_cartfs_workspace_clean = True
def _init_cartfs_workspace_snapshot(
self,
snapshot_function: Callable[[Path, Path, Path], None] | None = None,
) -> None:
"""Snapshots and links to the workspace from the most recent cartfs directory."""
if snapshot_function is None:
snapshot_function = snapshotter.snapshot_workspace
snapshot_src = (
self._golden_snapshot_dir or self._find_previous_instance()
)
if not snapshot_src:
logger.log_info("No previous cartfs workspace directory found.")
return
suggested_directory_name = self.cartfs_instance.suggest_cartfs_dir_name(
sanitize_filename(f"{self.workspace_name}-{self.workspace_id}")
)
try:
snapshot_function(
snapshot_src,
suggested_directory_name,
self.cartfs_instance.mount_point,
)
except Exception:
logger.log_exception("An error occurred while snapshotting.")
return
self._link_to_cartfs(
self.cartfs_instance.mount_point / suggested_directory_name
)
# Trigger Cog workspace sync if we initialized from the golden snapshot.
if snapshot_src == self._golden_snapshot_dir:
self._is_cartfs_workspace_clean = True
self._sync_cog_to_golden_revision()
def _sync_cog_to_golden_revision(self) -> None:
logger.emit_status(
"Syncing Cog CITC base to match golden snapshot superproject..."
)
superproject_dir = self.cartfs_dir / "fuchsia-cog-superproject"
if not superproject_dir.is_dir():
logger.log_error(
f"fuchsia-cog-superproject directory not found in CartFS at {superproject_dir}"
)
return
try:
# Read the superproject commit hash from our isolated snapshot in CartFS
superproject_commit = self._run(
["git", "rev-parse", "HEAD"],
cwd=superproject_dir,
capture_output=True,
).strip()
except subprocess.CalledProcessError as e:
err_msg = e.stderr.decode("utf-8", errors="ignore").strip() or None
logger.log_error(
f"Failed to get superproject commit from CartFS snapshot: {e}. Output: {err_msg}"
)
return
logger.log_info(
f"Syncing Cog superproject '{self.repo_name}' to {superproject_commit}"
)
try:
self._run(
[
"git-citc",
"api.call",
"Rebase",
f'repo_root: "{self.repo_name}" new_base: "{superproject_commit}"',
],
cwd=self.repo_dir,
capture_output=True,
)
logger.emit_status("Cog CITC base synced successfully.")
except subprocess.CalledProcessError as e:
err_msg = e.stderr.decode("utf-8", errors="ignore").strip() or None
logger.log_error(
f"Failed to sync Cog CITC base: {e}. Output: {err_msg}"
)
def _init_cartfs_workspace_empty(self) -> None:
"""Links to a new, empty directory in the cartfs mount for this workspace.
This method generates a unique directory name based on the workspace name,
creates the directory, and writes a `.cog.json` metadata file into it.
"""
suggested_directory_name = self.cartfs_instance.suggest_cartfs_dir_name(
sanitize_filename(f"{self.workspace_name}-{self.workspace_id}")
)
cartfs_workspace_dir = (
self.cartfs_instance.mount_point / suggested_directory_name
)
cartfs_workspace_dir.mkdir()
self._link_to_cartfs(cartfs_workspace_dir)
def _link_to_cartfs(self, cartfs_dir: Path) -> None:
"""Links the cog workspace to a cartfs directory.
This creates a symlink named `cartfs-dir` inside the repository
directory of the cog workspace. This symlink points to the specified
cartfs directory, establishing the link between them. If a symlink
already exists, it will be replaced.
Additionally, it writes a `.cog.json` metadata file into the cartfs
directory.
Args:
cartfs_dir: The absolute path to the target cartfs directory.
"""
symlink_path = self.repo_dir / CARTFS_SYMLINK_NAME
# Create an absolute symlink from the repo directory to the cartfs
# workspace directory. If a symlink already exists, remove it first.
if symlink_path.is_symlink():
symlink_path.unlink()
symlink_path.symlink_to(cartfs_dir)
metadata = CogMetadata(
workspace_name=self.workspace_name,
repo_name=self.repo_name,
workspace_id=self.workspace_id,
)
metadata.write(cartfs_dir)
# Invalidate the cached property.
vars(self).pop("cartfs_dir", None)
def _find_previous_instance(self) -> Path | None:
"""Finds the most recent cartfs directory for the same repo.
This method iterates through all directories in the cartfs mount point,
looking for directories that are linked to a workspace with the same repo
name as the current one. It then returns the path to the one with the
most recent modification time.
Returns:
The path, relative to the cartfs mount point, to the newest
directory found, or None if no instances are found.
"""
mount_point = Path(self.cartfs_instance.mount_point)
if not mount_point or not mount_point.is_dir():
return None
candidates = set()
for entry in mount_point.iterdir():
if not entry.is_dir():
continue
metadata = CogMetadata.from_file(entry / COG_METADATA_FILE_NAME)
if not metadata:
continue
repo_name = metadata.repo_name
# Check if it's for the same repo.
if repo_name != self.repo_name:
continue
candidates.add(entry)
newest_candidate = None
newest_mtime = -1.0
for candidate in candidates:
try:
mtime = candidate.stat().st_mtime
if mtime > newest_mtime:
newest_mtime = mtime
newest_candidate = candidate
except FileNotFoundError:
# The directory was deleted between listing and stat-ing.
continue
return (
newest_candidate.relative_to(mount_point)
if newest_candidate
else None
)
def is_checkout_uptodate(self) -> bool:
"""Checks if the CartFS checkouts are up to date with Cog."""
self._assert_locked()
if self.config.get("useJiriUpdateOnly", False):
# In Superproject, we will only check the integration commit is
# aligned.
cog_integration_commit = self.get_cog_commit(
self.config["repo"]["integration"]
)
cartfs_integration_commit = self.get_cartfs_commit("integration")
logger.log_debug(
f"Cog integration commit: {cog_integration_commit}"
)
logger.log_debug(
f"CartFS integration commit: {cartfs_integration_commit}"
)
return cog_integration_commit == cartfs_integration_commit
else:
# In fuchsia, we will check fuchsia commit is aligned.
cog_fuchsia_commit = self.get_cog_commit(
self.config["repo"]["fuchsia"]
)
cartfs_fuchsia_commit = self.get_cartfs_commit("fuchsia")
logger.log_debug(f"Cog Fuchsia commit: {cog_fuchsia_commit}")
logger.log_debug(f"CartFS Fuchsia commit: {cartfs_fuchsia_commit}")
return cog_fuchsia_commit == cartfs_fuchsia_commit
def checkout_cartfs_to_cog_revisions(self) -> None:
"""Checkouts the CartFS fuchsia and integration repos to match the revisions in Cog."""
self._assert_locked()
if not self._is_jiri_bootstrapped():
self._bootstrap_jiri()
cog_integration_repo = self.config["repo"]["integration"]
cog_fuchsia_commit = self.get_cog_commit(self.config["repo"]["fuchsia"])
cog_integration_commit = cog_integration_repo and self.get_cog_commit(
cog_integration_repo
)
# Update CartFS integration and fuchsia checkouts.
if cog_integration_repo:
self._reinit_integration_repo(cog_integration_commit)
else:
self._reinit_integration_repo()
# Try to find the integration commit that rolled `cog_fuchsia_commit`.
logger.log_debug(
f"Current CartFS integration commit: {self.get_cartfs_commit('integration')}"
)
cog_integration_commit = self._checkout_integration_roll(
cog_fuchsia_commit
)
logger.log_debug(
f"New CartFS integration commit: {cog_integration_commit}"
)
logger.emit_status(
"Updating CartFS fuchsia and integration checkouts..."
)
self.cartfs_fuchsia_dir.mkdir(parents=True, exist_ok=True)
self._write_jiri_manifest()
self._write_jiri_config()
if self.config.get("useJiriUpdateOnly", False):
if (self.cartfs_fuchsia_dir / "integration").is_symlink():
(self.cartfs_fuchsia_dir / "integration").unlink()
self._update_jiri_checkout()
else:
self._sync_fuchsia_repo(cog_fuchsia_commit)
self._fetch_prebuilts()
self._create_symlinks()
# Record the updated commit hashes in CartFS.
(self.cartfs_dir / ".fuchsia_commit_hash").write_text(
cog_fuchsia_commit
)
(self.cartfs_dir / ".integration_commit_hash").write_text(
cog_integration_commit
)
def get_cog_commit(self, repository: str) -> str:
"""Determines the `repository` commit hash from CitC."""
repo_states = (
self._run(
["git-citc", "api.get-repo-states", repository],
cwd=self.repo_dir,
capture_output=True,
)
.strip()
.split("\n")
)
for state in repo_states:
parts = state.split(":", 1)
if len(parts) == 2:
key = parts[0].strip().strip("'\"")
if key == "base_commit_hash":
return parts[1].strip().strip("'\"")
logger.log_error(f"Failed to get {repository} repo commit hash.")
raise RepoSetupError(f"Failed to get {repository} repo commit hash.")
def get_cartfs_commit(
self, repository: Literal["fuchsia", "integration"]
) -> str | None:
"""Determines the fuchsia or integration repo commit hash from CartFS."""
self._assert_locked()
hash_file = self.cartfs_dir / f".{repository}_commit_hash"
if not hash_file.is_file():
return None
try:
return hash_file.read_text().strip()
except Exception:
return None
def _create_symlink(self, target: Path, link_name: Path) -> None:
"""Creates a symlink from link_name to target.
If a symlink already exists at link_name and points to target, this
function does nothing.
If a file, directory, or a different symlink exists at link_name, it will
be removed and replaced with the new symlink.
"""
if link_name.is_symlink() and link_name.readlink() == target:
return
# If the path exists but is not the desired symlink, remove it.
if link_name.is_dir() and not link_name.is_symlink():
shutil.rmtree(link_name)
else:
link_name.unlink(missing_ok=True)
if not link_name.parent.is_dir():
link_name.parent.mkdir(parents=True, exist_ok=True)
logger.log_debug(f"Creating symlink from {link_name} to {target}")
link_name.symlink_to(target)
def _write_jiri_manifest(self) -> None:
"""Writes the jiri manifest."""
logger.emit_status("Writing jiri manifest...")
jiri_manifest = self.cartfs_dir / "fuchsia" / ".jiri_manifest"
content = (
"<manifest><imports>"
+ "\n".join(
f'<localimport file="{file}"/>'
for file in self.config["jiriImports"]
)
+ "</imports></manifest>\n"
)
jiri_manifest.parent.mkdir(parents=True, exist_ok=True)
if not jiri_manifest.exists() or jiri_manifest.read_text() != content:
jiri_manifest.write_text(content)
logger.log_debug(f"Wrote jiri manifest to {jiri_manifest}")
def _write_jiri_config(self) -> None:
"""Initializes the jiri config."""
logger.emit_status("Initializing jiri config...")
(self.cartfs_fuchsia_dir / ".jiri_root" / "bin").mkdir(
exist_ok=True, parents=True
)
self._create_symlink(
self.cartfs_mount_point / ".jiri_root" / "bin" / "jiri",
self.cartfs_fuchsia_dir / ".jiri_root" / "bin" / "jiri",
)
self._run(
[
".jiri_root/bin/jiri",
"init",
"-analytics-opt=true",
],
cwd=self.cartfs_fuchsia_dir,
)
# Clean out any local changes from the CartFS checkout before running `jiri update`.
# This isn't necessary if we just initialized from a golden snapshot or just created an
# empty workspace.
if not self._is_cartfs_workspace_clean:
self._run(
[".jiri_root/bin/jiri", "runp", "git", "clean", "-df"],
cwd=self.cartfs_fuchsia_dir,
)
self._run(
[".jiri_root/bin/jiri", "runp", "git", "restore", "."],
cwd=self.cartfs_fuchsia_dir,
)
def _is_jiri_bootstrapped(self) -> bool:
"""Checks if jiri is bootstrapped."""
return (
self.cartfs_mount_point / ".jiri_root" / "bin" / "jiri"
).exists()
def _bootstrap_jiri(self) -> None:
"""Bootstraps jiri if it is not already bootstrapped."""
logger.emit_status("Bootstrapping jiri...")
url = "https://fuchsia.googlesource.com/jiri/+/HEAD/scripts/bootstrap_jiri?format=TEXT"
try:
with urllib.request.urlopen(url, timeout=30) as response:
encoded_script = response.read()
decoded_script = base64.b64decode(encoded_script)
subprocess.run(
["bash", "-s", self.cartfs_mount_point],
input=decoded_script,
check=True,
)
except (urllib.error.URLError, subprocess.CalledProcessError) as e:
logger.log_error(f"Failed to bootstrap jiri: {e}")
raise
def _update_jiri_checkout(self) -> None:
"""Updates the jiri checkout."""
logger.emit_status("Updating jiri checkout...")
logger.log_info(f"Running jiri update in {self.cartfs_fuchsia_dir}")
cmd = (
[".jiri_root/bin/jiri", "-v", "update"]
if logger.get_log_level() <= logging.DEBUG
else [".jiri_root/bin/jiri", "update"]
)
env = os.environ.copy()
env["FUCHSIA_DIR"] = str(self.cartfs_fuchsia_dir)
subprocess.run(
cmd,
cwd=self.cartfs_fuchsia_dir,
check=True,
env=env,
)
def _fetch_prebuilts(self) -> None:
"""Fetches prebuilts for the given repo."""
logger.emit_status(f"Fetching prebuilts for {self.repo_name}...")
cartfs_fuchsia_dir = self.cartfs_fuchsia_dir
# Run jiri update and fetch-packages in parallel to speed up the
# process.
update_process = subprocess.Popen(
[".jiri_root/bin/jiri", "update", "--fetch-packages=false"],
cwd=cartfs_fuchsia_dir,
)
fetch_process = subprocess.Popen(
[".jiri_root/bin/jiri", "fetch-packages"], cwd=cartfs_fuchsia_dir
)
update_process.wait()
fetch_process.wait()
# Create update history file manually since update with --fetch-packages=false doesn't do it.
self._run(
[
".jiri_root/bin/jiri",
"snapshot",
".jiri_root/update_history/latest",
],
cwd=cartfs_fuchsia_dir,
)
def _reinit_integration_repo(self, revision: str | None = None) -> None:
"""Destroys and re-clones the `integration` checkout in CartFS, with a depth of 100 cls."""
logger.emit_status(f"Reinitializing the integration repository...")
integration_dir = self.cartfs_dir / "integration"
if integration_dir.is_dir():
shutil.rmtree(integration_dir)
remote = self.config["integration_url"]
git_clone_cmd = [
"git",
"clone",
remote,
"integration",
"--depth=100",
]
if revision:
git_clone_cmd.append(f"--revision={revision}")
logger.emit_status("Cloning integration repo...")
self._run(git_clone_cmd, self.cartfs_dir)
def _checkout_integration_roll(self, fuchsia_commit: str) -> str:
"""Checks out the CartFS integration repo to the commit rolling `fuchsia_commit`.
This is no-op if a roll cl containing `fuchsia_commit` isn't found.
Returns the commit that was checked out.
"""
integration_dir = self.cartfs_dir / "integration"
if not integration_dir.is_dir():
raise RepoSetupError("No integration directory found.")
# We use the first 7 characters of the fuchsia repo to look up in
# integration repo's commit message
commit_hash_prefix = fuchsia_commit[:7]
logger.log_debug(f"Fuchsia commit_hash_prefix: '{commit_hash_prefix}'")
integration_base_commit_hash = (
self._run(
["git", "log", "--grep", commit_hash_prefix, "--format=%H"],
cwd=integration_dir,
capture_output=True,
)
.strip()
.split("\n")[-1]
)
logger.log_debug(
f"Associated integration_base_commit_hash: '{integration_base_commit_hash}'"
)
if not integration_base_commit_hash:
# TODO(https://fxbug.dev/500722390): This isn't completely correct.
logger.log_info(
"Fuchsia commit is not rolled to integration repo yet. We will "
"use the latest integration repo commit hash."
)
return self._run(
["git", "rev-parse", "HEAD"],
cwd=integration_dir,
capture_output=True,
).strip()
else:
# checkout the integration repo based on the fuchsia repo commit hash
self._run(
["git", "reset", "--hard", integration_base_commit_hash],
cwd=integration_dir,
)
return integration_base_commit_hash
def _sync_fuchsia_repo(self, commit: str) -> None:
"""Syncs the fuchsia repository to the specified commit hash."""
integration_dir = self.cartfs_dir / "integration"
if not integration_dir.is_dir():
raise RepoSetupError("No integration directory found.")
# clone fuchsia repository and reset it to the commit hash
logger.emit_status(
"Syncing the CartFS fuchsia checkout from "
f"{self.get_cartfs_commit('fuchsia')} to {commit}"
)
backup_dir = None
# For a newly created workspace, we would expect the fuchsia directory
# exist but not setup yet. In this case the .git support is not there
# yet so we skip resetting.
if (self.cartfs_fuchsia_dir / ".git").exists():
try:
subprocess.run(
["git", "reset", "--hard"],
cwd=self.cartfs_fuchsia_dir,
check=True,
capture_output=True,
)
except subprocess.CalledProcessError as e:
logger.log_warn(
f"git reset failed in CartFS, likely corruption: {e}"
)
logger.log_warn(
"Attempting recovery by deleting corrupted directory."
)
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
backup_dir = self.cartfs_fuchsia_dir.with_name(
f"fuchsia.broken.{timestamp}"
)
logger.log_warn(f"Moving corrupted directory to {backup_dir}")
os.rename(self.cartfs_fuchsia_dir, backup_dir)
# Fix .git/HEAD in backup directory to allow git commands
backup_git_head = backup_dir / ".git" / "HEAD"
try:
backup_git_head.write_text("ref: refs/heads/main")
except Exception as e:
logger.log_warn(f"Failed to fix .git/HEAD in backup: {e}")
if not (self.cartfs_fuchsia_dir / ".git").exists():
# We fetch fuchsia repository from the last 4 days because git hook will
# refer to commit from yesterday to generate integration_daily_commit_hash.
integration_commit_timestamp = int(
self._run(
["git", "show", "-s", "--format=%ct"],
cwd=integration_dir,
capture_output=True,
).strip()
)
integration_commit_time = datetime.fromtimestamp(
integration_commit_timestamp
)
four_days_ago = (
integration_commit_time - timedelta(days=4)
).strftime("%Y-%m-%d")
# We use a step-by-step approach (init, remote add, fetch, reset) instead of a single `git clone`
# because `git clone` was failing in the CartFS FUSE mount, likely due to I/O limitations
# during index-pack. Breaking it down allows us to bypass these filesystem issues.
# We use --shallow-since to keep a few days of history to ensure
# build integrity while keeping the operation fast.
self._run(["git", "init", "fuchsia"], cwd=self.cartfs_dir)
fuchsia_dir = self.cartfs_dir / "fuchsia"
self._run(
[
"git",
"remote",
"add",
"origin",
"https://fuchsia.googlesource.com/fuchsia",
],
cwd=fuchsia_dir,
)
self._run(
[
"git",
"fetch",
"origin",
commit,
f"--shallow-since={four_days_ago}",
],
cwd=fuchsia_dir,
)
self._run(["git", "reset", "--hard", "FETCH_HEAD"], cwd=fuchsia_dir)
self._run(
[
"git",
"fetch",
"origin",
"main:refs/remotes/origin/main",
],
cwd=fuchsia_dir,
)
else:
self._run(
[
"git",
"fetch",
"origin",
commit,
],
self.cartfs_fuchsia_dir,
)
self._run(
["git", "reset", "--hard", commit], self.cartfs_fuchsia_dir
)
if backup_dir:
logger.log_warn(
"Restoring ignored files and directories from backup."
)
cartfs_rel_root = self.cartfs_dir.relative_to(
self.cartfs_mount_point
)
# 1. Snapshot hardcoded large directories
hardcoded_dirs = ["out", "prebuilt", ".cipd", ".fx", ".jiri_root"]
for dir_name in hardcoded_dirs:
from_rel = cartfs_rel_root / backup_dir.name / dir_name
to_rel = (
cartfs_rel_root / self.cartfs_fuchsia_dir.name / dir_name
)
backup_path = backup_dir / dir_name
target_path = self.cartfs_fuchsia_dir / dir_name
if backup_path.exists():
try:
snapshotter.copy_cartfs_directory(from_rel, to_rel)
except Exception:
logger.log_exception(
f"Failed to restore {dir_name} via snapshot"
)
# Fallback to individual copy if it failed (e.g. ALREADY_EXISTS)
if backup_path.is_dir():
logger.log_warn(
f"Falling back to individual file copy for {dir_name}"
)
self._merge_directories(backup_path, target_path)
# 2. Dynamically discover other ignored files
try:
ignored_output = subprocess.run(
[
"git",
"ls-files",
"--others",
"--ignored",
"--exclude-standard",
],
cwd=backup_dir,
check=True,
capture_output=True,
text=True,
).stdout
for line in ignored_output.splitlines():
path_str = line.strip()
if not path_str:
continue
# Skip if it belongs to hardcoded dirs
if any(
path_str.startswith(d + "/") for d in hardcoded_dirs
):
continue
# Skip __pycache__
if "__pycache__" in path_str:
continue
backup_path = backup_dir / path_str
target_path = self.cartfs_fuchsia_dir / path_str
if backup_path.is_file():
if not target_path.exists():
try:
target_path.parent.mkdir(
parents=True, exist_ok=True
)
shutil.copyfile(backup_path, target_path)
except Exception as e:
logger.log_error(
f"Failed to copy file {path_str}: {e}"
)
except subprocess.CalledProcessError as e:
logger.log_error(f"Failed to list ignored files: {e}")
# Couple of places in the build expect to find JIRI_HEAD for fuchsia
# repository. In a normal checkout by jiri, the JIRI_HEAD is created
# automatically.
# https://fuchsia.googlesource.com/jiri/+/refs/heads/main/project/project.go#689
# For our cartfs checkout, we need to create it manually.
if not self.config.get("useJiriUpdateOnly", False):
shutil.copyfile(
self.cartfs_fuchsia_dir / ".git" / "HEAD",
self.cartfs_fuchsia_dir / ".git" / "JIRI_HEAD",
)
def _merge_directories(self, src: Path, dst: Path) -> None:
"""Recursively copies files from src to dst, not overwriting existing files."""
for item in src.iterdir():
s = src / item.name
d = dst / item.name
if s.is_symlink():
if not (d.exists() or d.is_symlink()):
try:
d.parent.mkdir(parents=True, exist_ok=True)
os.symlink(os.readlink(s), d)
except Exception as e:
logger.log_error(
f"Failed to copy symlink {s} to {d}: {e}"
)
elif s.is_dir():
self._merge_directories(s, d)
else:
if not d.exists():
try:
d.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(s, d)
except Exception as e:
logger.log_error(f"Failed to copy {s} to {d}: {e}")
def _create_symlinks(self) -> None:
"""Creates symlinks for the prebuilts."""
logger.emit_status("Creating symlinks...")
# Link the paths in the repo to cartfs
(self.cartfs_fuchsia_dir / ".fx" / "config").mkdir(
exist_ok=True, parents=True
)
def _get_path(path: str) -> Path:
root, relative_path = path.split("//", 1)
return {
"@cartfs": self.cartfs_dir,
"@cog": self.repo_dir,
}[root] / relative_path
for dest, src in self.config["symlinkMap"].items():
self._create_symlink(_get_path(src), _get_path(dest))
if self.config.get("useJiriUpdateOnly", False):
# If we are using jiri update only, we don't need to run jiri hooks
# manually since jiri will do it for us.
return
# Manually execute jiri hooks. The hooks are defined in
# https://fuchsia.googlesource.com/fuchsia/+/refs/heads/main/manifests/platform#14
# and are automatically executed by jiri during `jiri update`. Since we
# are not using `jiri update`, we need to execute them manually.
hooks = [
"scripts/devshell/lib/add_symlink_to_bin.sh",
"sdk/ctf/build/internal/create_ctf_releases_gni.sh",
"build/info/create_jiri_hook_files.sh",
"tools/build/scripts/generate_prebuilt_versions.sh",
"tools/build/scripts/extract_protobuf_py3_wheel.sh",
"tools/build/scripts/extract_pydantic_core_wheel.sh",
]
for hook in hooks:
self._run([hook], self.cartfs_fuchsia_dir)
# Invoke git status in the fuchsia directory in the background. This
# will make the future `fx format-code` command faster.
subprocess.Popen(
["git", "status"],
cwd=self.repo_dir,
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def _run(
self,
cmd: list[str],
cwd: Path,
capture_output: bool = False,
) -> str:
"""Runs a command."""
logger.log_debug(f"Running command: '{' '.join(cmd)}' in {cwd}")
# Set FUCHSIA_DIR environment variable to the cartfs fuchsia directory.
# This is needed for the hooks to work correctly.
env = os.environ.copy()
env["FUCHSIA_DIR"] = str(self.cartfs_fuchsia_dir)
# If we are not debugging, we want to capture the output so we can print it on error.
# If we are debugging, stdout/stderr are None, so output goes to stdout/stderr.
run_capture_output = (
capture_output or logger.get_log_level() > logging.DEBUG
)
process = subprocess.run(
cmd,
cwd=cwd,
check=True,
capture_output=run_capture_output,
env=env,
)
return (
process.stdout.decode("utf-8", errors="ignore")
if capture_output
else ""
)