blob: 4aafaab8448dc2dd09b8f9d05657e820e6be08e2 [file] [edit]
# Copyright 2026 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 json
import os
import subprocess
import sys
import time
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from build_dir import BuildDir
from utils import run_git
class NoFreeWorktreesError(RuntimeError):
pass
class WorktreeState(Enum):
FREE = "free"
LEASED = "leased"
class SyncStatus(Enum):
SYNCED = "Synced"
BEHIND = "behind"
NEW = "new"
DIVERGED = "behind, new"
UNKNOWN = "Unknown"
@dataclass
class LeaseInfo:
worktree_id: str
pid: int
timestamp_sec: int
task_id: str | None = None
class Worktree:
def __init__(self, name: str, path: Path, main_checkout_dir: Path):
self.name = name
self.path = Path(path).resolve()
self.main_checkout_dir = Path(main_checkout_dir).resolve()
self.meta_dir = self.path / ".jiri_root"
self.state_file = self.meta_dir / "worktree-state"
self.lease_file = self.meta_dir / "lease.json"
def get_state(self) -> WorktreeState:
if self.lease_file.exists():
return WorktreeState.LEASED
if not self.state_file.exists():
return WorktreeState.FREE
state_str = self.state_file.read_text().strip()
try:
return WorktreeState(state_str)
except ValueError:
return WorktreeState.FREE
def set_state(self, state: WorktreeState) -> None:
self.meta_dir.mkdir(parents=True, exist_ok=True)
self.state_file.write_text(f"{state.value}\n")
def get_lease_info(self) -> LeaseInfo | None:
if not self.lease_file.exists():
return None
try:
content = json.loads(self.lease_file.read_text())
return LeaseInfo(
worktree_id=content.get("worktree_id", self.name),
pid=content.get("pid", 0),
timestamp_sec=content.get("timestamp_sec", 0),
task_id=content.get("task_id"),
)
except Exception:
return None
@property
def build_dirs(self) -> list[BuildDir]:
dirs = set()
fx_build_dir_file = self.path / ".fx-build-dir"
if fx_build_dir_file.exists():
build_dir_rel = fx_build_dir_file.read_text().strip()
if build_dir_rel:
try:
dirs.add((self.path / build_dir_rel).resolve())
except OSError:
pass
else:
try:
dirs.add((self.path / "out" / "default").resolve())
except OSError:
pass
out_dir = self.path / "out"
if out_dir.exists():
for child in out_dir.iterdir():
if child.is_dir():
if (child / "args.gn").exists() or (
child / "build.ninja"
).exists():
try:
dirs.add(child.resolve())
except OSError:
pass
return [BuildDir(d) for d in sorted(dirs) if d.exists() and d.is_dir()]
def get_sync_status(self) -> tuple[SyncStatus, int, int]:
try:
main_head = run_git(
self.main_checkout_dir,
["rev-parse", "HEAD"],
quiet=True,
check=True,
capture_output=True,
text=True,
).stdout.strip()
wt_head = run_git(
self.path,
["rev-parse", "HEAD"],
quiet=True,
check=True,
capture_output=True,
text=True,
).stdout.strip()
behind = int(
run_git(
self.main_checkout_dir,
["rev-list", "--count", f"{wt_head}..{main_head}"],
quiet=True,
check=True,
capture_output=True,
text=True,
).stdout.strip()
)
new = int(
run_git(
self.main_checkout_dir,
["rev-list", "--count", f"{main_head}..{wt_head}"],
quiet=True,
check=True,
capture_output=True,
text=True,
).stdout.strip()
)
if behind == 0 and new == 0:
return SyncStatus.SYNCED, 0, 0
if behind > 0 and new == 0:
return SyncStatus.BEHIND, behind, 0
if behind == 0 and new > 0:
return SyncStatus.NEW, 0, new
return SyncStatus.DIVERGED, behind, new
except (subprocess.CalledProcessError, FileNotFoundError):
return SyncStatus.UNKNOWN, 0, 0
def acquire_lease(self, task_id: str | None = None) -> None:
state = self.get_state()
if state != WorktreeState.FREE:
raise RuntimeError(
f"Worktree '{self.name}' is not free (state: {state.value})"
)
try:
self.meta_dir.mkdir(parents=True, exist_ok=True)
fd = os.open(self.lease_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL)
lease_data = {
"worktree_id": self.name,
"pid": os.getpid(),
"timestamp_sec": int(time.time()),
"path": str(self.path),
}
if task_id:
lease_data["task_id"] = task_id
with os.fdopen(fd, "w") as f:
json.dump(lease_data, f, separators=(",", ":"))
except FileExistsError:
raise RuntimeError(f"Worktree '{self.name}' is already leased")
for bd in self.build_dirs:
bd.backup_args()
self.set_state(WorktreeState.LEASED)
def release_lease(self) -> None:
state = self.get_state()
if state != WorktreeState.LEASED:
raise RuntimeError(
f"Cannot release '{self.name}': worktree is not leased (state: {state.value})"
)
for bd in self.build_dirs:
bd.restore_args()
try:
run_git(self.path, ["checkout", "--detach"], quiet=True, check=True)
except Exception as e:
print(
f"Warning: failed to detach branch in worktree '{self.name}': {e}",
file=sys.stderr,
)
try:
if self.lease_file.exists():
os.remove(self.lease_file)
except OSError as e:
raise RuntimeError(f"Failed to remove lease file: {e}")
self.set_state(WorktreeState.FREE)