#!/usr/bin/env python3
"""Update a packaged source tree per the "Updating" section of PACKAGING.md."""

import atexit
import os
import re
import shutil
import subprocess
import sys

import click
import git

# Base branch names. These carry a maintenance-line suffix (e.g. "-v1.3")
# when update-this targets a release cut on a hotfix/maintenance branch;
# bare otherwise. They are mutated in main() once the suffix is known and
# read by teardown_worktree()/finish_target()/prepare_update_branch() via
# the module globals MAIN and DEB.
MAIN = "main"
DEB = "deb"

DEV_BRANCHES = ("main", "master", "dev", "develop", "development")


def die(msg, code=1):
    """Print *msg* to stderr and exit."""
    print(f"{sys.argv[0]}: {msg}", file=sys.stderr)
    sys.exit(code)


_VERBOSE = False


def say(*args):
    """Emit a progress line to stderr iff --verbose is in effect."""
    if _VERBOSE:
        print(*args, file=sys.stderr, flush=True)


def run_debchange(repo_path, version):
    """Invoke ``debchange`` once to record the merge and release it.

    Combines the version/massage/release into a single call so no editor
    is spawned::

        debchange -v VERSION -m "Merge main" -D smurf --force-distribution
    """
    cmd = [
        "debchange",
        "-v", version,
        "-m", "Merge main",
        "-D", "smurf",
        "--force-distribution",
    ]
    res = subprocess.run(cmd, cwd=repo_path,
                        stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
    if res.returncode != 0:
        sys.stderr.write(res.stdout)
        die(f"debchange failed (exit {res.returncode})")
    # debchange prints a benign "Recognised distributions ..." warning on
    # stderr/stdout when handed a non-stock distribution like "smurf";
    # swallow it so it does not pollute the normal transcript.


def get_ref_commit(repo, refname):
    """Return the Commit for *refname*, or raise KeyError if absent."""
    try:
        return repo.refs[refname].commit
    except (IndexError, KeyError):
        raise KeyError(refname)


def ref_exists(repo, refname):
    """True iff *refname* (e.g. ``refs/heads/foo``) resolves to a commit.

    ``git rev-parse --verify --quiet`` exits non-zero for a missing ref and
    GitPython turns that into a raised GitCommandError (ignoring ``--quiet``),
    so we catch it rather than parse output.
    """
    try:
        repo.git.rev_parse("--verify", "--quiet", refname)
    except git.exc.GitCommandError:
        return False
    return True


def iter_committish_tags(repo):
    """Yield ``(name, Commit)`` for every tag whose dereferenced object is a
    commit. Both lightweight tags (``tag.tag is None``) and annotated tags
    are supported; ``tag.commit`` fully peels an annotated tag down to its
    commit. Tags ultimately pointing at blobs/trees are skipped silently."""
    for tag in repo.tags:
        try:
            commit = tag.commit
        except Exception:
            continue            # tag points at a non-commit object
        if isinstance(commit, git.Commit):
            yield tag.name, commit


def looks_like_dev_tag(name):
    """True for tags that smell like a development/prerelease marker and
    thus must not be selected as the target upstream version.

    Catches trailing dev/alpha/beta/rc qualifiers with any separator and
    optional trailing digits/dots/underscores, e.g. 1.2.3-rc1,
    v2.0.0-beta.2, 1.2.3.dev0, 1.2.3-alpha.1."""
    low = name.lower()
    if low.endswith(("dev", "alpha", "beta", "rc", "pre", "preview")):
        return True
    return bool(re.search(r'(?:^|[\d.-])(?:dev|alpha|beta|rc|pre|preview)(?:[\d._]*)$', low))


def strip_v_prefix(name):
    """Strip a leading ``v`` (case-insensitive) from a tag name."""
    return name[1:] if name and name[0] in "vV" else name


def discover_target_upstream(repo):
    """Pick the newest non-development upstream release tag and decide
    whether it rides a maintenance line.

    Selection rule ("latest non-development tag" per PACKAGING.md): scan
    EVERY committish tag in the repo (not just those reachable from a dev
    branch), keep only ones shaped like a release version (dot-separated
    digits, optional leading v/V), exclude development markers, and choose
    the NEWEST by the tagged commit's timestamp.

    Reachability check: the chosen tag is tested against each of
    origin/{main,master,dev[el[opment]]}. If it descends from one, the
    release lives on the mainline and the caller proceeds on the ordinary
    ``main``/``deb`` branches. If NOT, the release was cut on a maintenance
    branch (hotfix/backport line) and the caller must route the update onto
    ``main-<suffix>``/``deb-<suffix>`` instead -- see :option:`-b`.

    Why recency and not numeric ordering: this repo carries legacy tags
    whose parsed version tuples outrank current releases (e.g. ``v040.15``
    parses as ``(40, 15)`` and beats ``v2.4.2`` = ``(2, 4, 2)``, yet dates
    to 2022). Numeric comparison would therefore pin a stale target; the
    commit timestamp reflects the actual release cadence.

    Returns ``(name, commit, on_mainline)`` where *on_mainline* is True iff
    the tag is reachable from at least one qualifying dev branch.
    """

    def is_version_shaped(name):
        """True iff *name* (with any leading ``v``/``V`` stripped) is a
        non-empty run of dot-separated decimal components, e.g. ``2.4.2``,
        ``v1.524``, ``040.15``. Used only as a release-shape gate; the
        values themselves are never compared numerically."""
        core = strip_v_prefix(name)
        return bool(core) and all(part.isdigit() for part in core.split("."))

    # Collect the dev-branch tips once; reused for the reachability test.
    dev_tips = []
    for br in DEV_BRANCHES:
        try:
            dev_tips.append(get_ref_commit(repo, f"origin/{br}").hexsha)
        except KeyError:
            continue

    candidates = []          # (commit_date, name, commit)
    seen_commits = set()
    for name, commit in iter_committish_tags(repo):
        if looks_like_dev_tag(name):
            continue
        if not is_version_shaped(name):
            continue     # ignore non-release-shaped tags outright
        if commit.hexsha in seen_commits:
            continue
        seen_commits.add(commit.hexsha)
        candidates.append((commit.committed_date, name, commit))

    if not candidates:
        die("no suitable non-development upstream release tag found")

    # Newest by commit timestamp; name breaks timestamp ties deterministically.
    candidates.sort(key=lambda c: (c[0], c[1]))
    _, name, commit = candidates[-1]

    on_mainline = any(repo.is_ancestor(commit.hexsha, tip) for tip in dev_tips)
    return name, commit, on_mainline


def maint_suffix_from_tag(tag_name):
    """Derive the maintenance-line suffix from a release tag.

    Drops the PATCH component (the last dot-segment) of the tag, keeping any
    leading ``v``/``V`` prefix on the result, e.g. ``v1.3.1`` -> ``v1.3``,
    ``2.4.2`` -> ``2.4``. Per the requirement ("the tag without the patch
    number"), only the patch -- the third component of a MAJOR.MINOR.PATCH
    tag -- is removed. A tag with fewer than three components has no patch
    to drop, so the whole tag identifies the release series and is returned
    unchanged (e.g. ``v1.524`` -> ``v1.524``, ``1.0`` -> ``1.0``)."""
    had_v = tag_name[:1] in ("v", "V")
    core = strip_v_prefix(tag_name)
    comps = core.split(".")
    base = ".".join(comps[:-1]) if len(comps) >= 3 else core
    return ("v" if had_v else "") + base


def ensure_clean(repo):
    if repo.is_dirty(index=True, working_tree=True,
                     untracked_files=True, submodules=True):
        die("working tree is not clean")


def safe_fetch(repo, remote_name):
    """Fetch *remote_name*; die concisely if the remote is unknown or the
    fetch fails."""
    if remote_name not in [rm.name for rm in repo.remotes]:
        die(f"no '{remote_name}' remote configured")
    try:
        repo.remotes[remote_name].fetch(prune=True)
    except git.exc.GitError as exc:
        die(f"git fetch {remote_name} failed: {exc}")


def safe_checkout(repo, branch):
    try:
        repo.git.checkout(branch)
    except git.exc.GitError as exc:
        die(f"checkout '{branch}' failed: {exc}")


def safe_merge(repo, refspec, label=None, no_ff=True):
    """Merge *refspec* into the current branch.

    Returns True if HEAD actually moved (a real merge / fast-forward
    happened), False if it was a genuine no-op (``Already up to date``).
    Dies on conflicts or unexpected failures.

    No-op detection is done by comparing HEAD's SHA before and after:
    ``git merge --no-ff <ref>`` exits 0 and leaves HEAD untouched when the
    ref is already contained, whereas a successful merge always moves
    HEAD (either via a fresh merge commit or a fast-forward). String
    sniffing of git's prose is brittle across locales/versions.

    ``no_ff`` governs whether ``--no-ff`` is passed: it is mandatory for
    merges *into* ``deb`` so that a fast-forward cannot discard the deb
    branch's packaging divergence; merges into ``main`` may fast-forward
    for tidier history.
    """
    what = label or refspec
    before = repo.head.commit.hexsha
    # --no-verify keeps site-wide commit hooks (e.g. pre-commit) from
    # hijacking an unattended update; matches the documented -n on amend.
    args = ["--no-verify"] + (["--no-ff"] if no_ff else []) + [refspec]
    try:
        repo.git.merge(*args)
    except git.exc.GitError as exc:
        die(f"merge {what} failed: {strip_giterror(str(exc))}")
    return repo.head.commit.hexsha != before


def strip_giterror(msg):
    """Reduce noisy GitError strings to something concise for humans."""
    m = re.search(r"stderr:\s*'(.*)'", msg, re.S)
    return (m.group(1).replace("\\n", "\n").strip() if m else msg.strip())


# --- concurrency infrastructure -------------------------------------------
#
# The update mutates two long-lived branches (``main`` and ``deb``) and runs
# side-effectful build/upload steps (``debch``). Two simultaneous invocations
# would race on the shared refs and clobber each other's uploads. We guard
# against that by serializing EVERY run -- discovery, mutation, and upload
# alike -- on a single PID-stamped lockfile in the repository's common git
# directory.
#
# Lockfile name note: we use ``.git/update-this.lock``, deliberately distinct
# from the ``.git/merge-in.lock`` that ``git-merge-in`` (spawned from
# :func:`finish_target`) takes in the SAME common dir. Different names mean
# the child ``git-merge-in`` cannot self-deadlock against us, yet we still
# hold one lock spanning the whole operation so two runs never interleave.

WORK_DIR = "/src/work"
LOCK_NAME = "update-this.lock"

# Held lock descriptor, populated by acquire_lock(); released on exit via
# the atexit hook installed alongside it.
_LOCK = {"path": None}


def _release_lock():
    """Drop the worktree lock acquired by :func:`acquire_lock` (atexit)."""
    if _LOCK["path"]:
        try:
            os.unlink(_LOCK["path"])
        except OSError:
            pass
        _LOCK["path"] = None


def acquire_lock(common_dir):
    """Take ``<common_dir>/update-this.lock`` via ``shlock``.

    ``shlock`` records the holder's PID and refuses to hand the lock out if
    the recorded PID is still alive, so a crashed predecessor is recovered
    from automatically. We stash the path for an atexit cleanup so the lock
    is dropped even on an untidy exit (exception, SIGTERM, ...).
    """
    lock_path = os.path.join(common_dir, LOCK_NAME)
    if shutil.which("shlock") is None:
        die("'shlock' not found on PATH (needed for locking)")
    res = subprocess.run(["shlock", "-p", str(os.getpid()), "-f", lock_path])
    if res.returncode != 0:
        try:
            holder = open(lock_path).read().strip()
        except OSError:
            holder = "?"
        die(f"already running (lock held by PID {holder})")
    _LOCK["path"] = lock_path
    atexit.register(_release_lock)


def get_beads_prefix(repo):
    """Return ``git config beads.prefix`` for *repo*, dying if unset/empty.

    The value (``NAME``) names the per-project staging worktree under
    ``/src/work`` (``/src/work/NAME-update``). An unset config is a fatal
    setup error rather than something to guess at.
    """
    try:
        val = repo.git.config("--get", "beads.prefix")
    except git.exc.GitCommandError:
        die("git config 'beads.prefix' is not set (needed to name the "
            "update worktree)")
    val = val.strip()
    if not val:
        die("git config 'beads.prefix' is empty (needed to name the "
            "update worktree)")
    return val


def ensure_worktree(orig_repo, name):
    """Create (or reuse) the staging worktree at ``/src/work/<name>-update``.

    Starts detached at HEAD; callers check out concrete branches inside it.
    Reusing an existing worktree tolerates a crashed predecessor (its on-disk
    tree is simply picked up). The worktree is torn down again on success or
    on a clean nothing-to-do exit -- see :func:`teardown_worktree`.

    Returns ``(wt_repo, wt_path)`` where ``wt_repo`` is a GitPython handle
    rooted at the worktree and ``wt_path`` its absolute top level.
    """
    wt_path = os.path.join(WORK_DIR, f"{name}-update")
    # Which worktrees are currently registered? (Porcelain lines beginning
    # with "worktree " introduce each entry.)
    registered = [
        line[len("worktree "):]
        for line in orig_repo.git.worktree("list", "--porcelain").splitlines()
        if line.startswith("worktree ")
    ]
    if wt_path not in registered:
        # Prune any stale metadata for a vanished directory, then add ours.
        orig_repo.git.worktree("prune")
        os.makedirs(WORK_DIR, exist_ok=True)
        try:
            orig_repo.git.worktree("add", "--detach", wt_path)
        except git.exc.GitError as exc:
            die(f"could not create worktree {wt_path}: {strip_giterror(str(exc))}")
    if not os.path.isdir(wt_path):
        die(f"worktree {wt_path} is registered but its directory is gone")
    try:
        wt_repo = git.Repo(wt_path)
    except git.exc.InvalidGitRepositoryError:
        die(f"worktree {wt_path} is not a git repository")
    return wt_repo, wt_path


def teardown_worktree(orig_repo, name, force=False):
    """Remove the staging worktree and any leftover ``*-update`` branches.

    Called on success (after upload, *force*=True so disposable build
    artifacts from ``debch`` don't strand the tree) and on a clean
    nothing-to-do exit (*force*=False: a dirty tree is left in place rather
    than destroyed, preserving any in-flight work).

    The scratch branches ``main-update`` and ``deb-update`` live in the
    common ref store and can outlive their worktree, so they are swept
    unconditionally (best-effort) -- a successful run has already deleted
    them via :func:`finish_target`, so this mainly recovers crash leftovers.
    """
    wt_path = os.path.join(WORK_DIR, f"{name}-update")
    registered = [
        line[len("worktree "):]
        for line in orig_repo.git.worktree("list", "--porcelain").splitlines()
        if line.startswith("worktree ")
    ]
    if wt_path in registered:
        say(f"removing worktree {wt_path}")
        try:
            orig_repo.git.worktree("remove", wt_path)
        except git.exc.GitError as exc:
            if not force:
                say(f"keeping worktree {wt_path}: {strip_giterror(str(exc))}")
                return
            say(f"force-removing worktree {wt_path}")
            try:
                orig_repo.git.worktree("remove", "--force", wt_path)
            except git.exc.GitError as exc2:
                say(f"could not remove worktree {wt_path}: "
                    f"{strip_giterror(str(exc2))}")
                return
    for br in (f"{MAIN}-update", f"{DEB}-update"):
        if ref_exists(orig_repo, f"refs/heads/{br}"):
            say(f"deleting leftover branch {br}")
            try:
                orig_repo.git.branch("-D", br)
            except git.exc.GitError as exc:
                say(f"could not delete {br}: {strip_giterror(str(exc))}")


def current_branch(path):
    """Return the branch checked out in the checkout at *path*, or None.

    None covers "not a repo", "detached HEAD", and any lookup failure --
    callers treat all three identically ("don't disturb the original dir").
    Uses plumbing so it never prints to stderr on a detached head.
    """
    try:
        out = subprocess.run(
            ["git", "symbolic-ref", "--short", "-q", "HEAD"],
            cwd=path, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True,
        )
    except (OSError, subprocess.SubprocessError):
        return None
    if out.returncode != 0:
        return None
    name = out.stdout.strip()
    return name or None


def prepare_update_branch(wt_repo, target):
    """Materialize ``<target>-update`` in the worktree as a copy of *target*.

    If the branch does not yet exist, create it from *target* and check it
    out. If it already exists (leftover from a prior interrupted run), check
    it out and merge *target* into it so any new commits on *target* are
    folded in before we proceed. Either way the worktree ends up on
    ``<target>-update``.
    """
    upd = f"{target}-update"
    has_branch = ref_exists(wt_repo, f"refs/heads/{upd}")
    if not has_branch:
        say(f"creating {upd} from {target}")
        try:
            wt_repo.git.checkout("-b", upd, target)
        except git.exc.GitError as exc:
            die(f"checkout -b {upd} {target} failed: {strip_giterror(str(exc))}")
    else:
        say(f"reusing existing {upd}; merging {target}")
        try:
            wt_repo.git.checkout(upd)
        except git.exc.GitError as exc:
            die(f"checkout {upd} failed: {strip_giterror(str(exc))}")
        safe_merge(wt_repo, target, label=target, no_ff=False)


def finish_target(wt_repo, orig_top, target):
    """Publish ``<target>-update`` back onto *target* and retire the branch.

    Two cases, per the assignment:

    * the original checkout currently has *target* checked out -- run
      ``git-merge-in <target>-update`` *there*. ``git-merge-in`` does a
      fast-forward merge under its own ``.git/merge-in.lock``, so the move
      is atomic and serialized against anyone touching that checkout.
    * otherwise -- check out *target* in the worktree and fast-forward
      merge ``<target>-update`` into it ourselves, leaving the worktree
      on *target* (handy: a subsequent debch then runs on a real branch).

    On success the temporary ``<target>-update`` branch is deleted. Any
    failure dies loudly, leaving ``<target>-update`` intact for inspection.
    """
    upd = f"{target}-update"
    orig_here = current_branch(orig_top) == target
    if orig_here:
        say(f"running git-merge-in {upd} in {orig_top}")
        res = subprocess.run(["git-merge-in", upd], cwd=orig_top)
        if res.returncode != 0:
            die(f"git-merge-in {upd} failed (exit {res.returncode}); "
                f"{upd} left for inspection")
        # The worktree is still parked on {upd} (only the original dir
        # moved); git refuses to delete a branch checked out in any
        # worktree, so detach first.
        try:
            wt_repo.git.checkout("--detach")
        except git.exc.GitError:
            pass
    else:
        say(f"checking out {target} in worktree and fast-forwarding {upd}")
        # Safe precisely because orig_here is False: *target* is not pinned
        # by the original checkout, so the worktree may borrow it. After
        # the fast-forward we sit on *target* (not on upd), so the branch
        # deletion below is legal WITHOUT detaching -- and staying on
        # *target* lets a later debch run on a real branch, not detached.
        try:
            wt_repo.git.checkout(target)
        except git.exc.GitError as exc:
            die(f"checkout {target} in worktree failed: {strip_giterror(str(exc))}")
        try:
            wt_repo.git.merge("--ff-only", upd)
        except git.exc.GitError as exc:
            die(f"fast-forward merge of {upd} into {target} failed: "
                f"{strip_giterror(str(exc))}; {upd} left for inspection")
    say(f"deleting branch {upd}")
    try:
        wt_repo.git.branch("-D", upd)
    except git.exc.GitError as exc:
        die(f"could not delete {upd}: {strip_giterror(str(exc))}")


@click.command()
@click.option("-C", "--repo", "repo_path", metavar="PATH", default=None,
              help="repository to operate on (default: cwd's enclosing repo)")
@click.option("-v", "--verbose", is_flag=True,
              help="narrate each step as it runs.")
@click.option("-b", "branch", metavar="SUFFIX",
              default=None,
              help="maintenance-line suffix (e.g. 'v1.3'); routes the "
                   "update onto main-SUFFIX/deb-SUFFIX. Auto-derived "
                   "when the newest release tag is unreachable from "
                   "origin/main; empty (plain main/deb) otherwise.")
def main(repo_path, verbose, branch):
    """Update a packaged source tree per the "Updating" section of PACKAGING.md.

    Operates on the git repository containing the current working directory
    (or the one given with -C/--repo). Uses the ``git`` Python package (never
    spawns git through subprocess directly), except for ``debchange``, which is
    an external program and therefore run via :mod:`subprocess`.

    Each step aborts with a concise error message if it fails in a way that
    cannot trivially be fixed.

    Maintenance lines (-b SUFFIX): when the newest release tag was cut on a
    hotfix/maintenance branch (unreachable from origin/main), the update is
    routed onto ``main-SUFFIX``/``deb-SUFFIX`` instead of the bare
    ``main``/``deb``. Those branches are created off ``main``/``deb`` on first
    use if they don't already exist; the intern counterparts
    ``intern/main-SUFFIX``/``intern/deb-SUFFIX`` are expected to exist (they
    spring into existence once a first run publishes via debch). SUFFIX defaults
    to the tag with its final component dropped (e.g. v1.3.1 -> v1.3) when the
    newest tag is off-mainline, and to empty (ordinary main/deb flow) when it
    is on the mainline; an explicit -b overrides either default.

    Concurrency safety: the whole run is serialized by a PID-stamped lockfile
    in the common git dir, so two simultaneous invocations cannot race on the
    shared branch refs. All mutation happens inside a dedicated,
    lazily-created worktree at ``/src/work/NAME-update`` (NAME =
    ``git config beads.prefix``), which is removed again on success or on a
    clean nothing-to-do exit. See the module-level comment near
    :func:`acquire_lock` for the lock-path rationale.
    """
    global _VERBOSE, MAIN, DEB
    _VERBOSE = verbose
    try:
        repo = git.Repo(repo_path, search_parent_directories=repo_path is None)
    except git.exc.NoSuchPathError:
        die(f"no such path: {repo_path}")
    except git.exc.InvalidGitRepositoryError:
        die("not inside a git repository")
    orig_top = repo.working_tree_dir.rstrip("/")
    # Short-SHA width follows the repo's configured abbreviation rather
    # than assuming git's default of 7 (core.abbrev / auto-growth).
    short_len = len(repo.git.rev_parse("--short", "HEAD"))

    # Step 1: ensure the ORIGINAL tree is clean ----------------------------
    # Only the original checkout matters for cleanliness; the worktree is
    # scratch space we fully control. We re-check it before publishing too.
    say("step 1: ensuring working tree is clean")
    ensure_clean(repo)

    # Resolve the per-project name (git config beads.prefix) that pins the
    # staging worktree's path (/src/work/NAME-update). Fatal if unset: we
    # never want to guess where scratch trees live.
    name = get_beads_prefix(repo)

    # Serialize the ENTIRE operation -- discovery, mutation, upload -- on a
    # PID-stamped lockfile in the COMMON git dir (shared by every worktree of
    # this repo, so a run launched from any checkout contends on the same
    # lock). Its name is distinct from git-merge-in's (.git/merge-in.lock),
    # so the merge-in that finish_target() spawns cannot self-deadlock
    # against us (see the module-level comment near acquire_lock).
    acquire_lock(repo.common_dir)
    say(f"locked {_LOCK['path']} (pid {os.getpid()})")

    # Steps 2 & 3: fetch remotes -------------------------------------------
    say("step 2-3: fetching remotes (origin, intern)")
    safe_fetch(repo, "origin")
    safe_fetch(repo, "intern")

    # Step 4: discover the target upstream version -------------------------
    tag_name, target_commit, on_mainline = discover_target_upstream(repo)
    say(f"step 4: target upstream tag {tag_name} "
        f"@ {target_commit.hexsha[:short_len]} "
        f"({'mainline' if on_mainline else 'maintenance'})")

    # Resolve the maintenance-line suffix (-b SUFFIX). An explicit value
    # wins. Otherwise it is auto-derived: empty when the newest tag rides
    # the mainline (ordinary main/deb flow), or the tag minus its final
    # component (e.g. v1.3.1 -> v1.3) when the tag was cut on a maintenance
    # branch and is unreachable from origin/main.
    if branch is not None:
        suffix = "" if branch == "-" else branch
    elif on_mainline:
        suffix = ""
    else:
        suffix = maint_suffix_from_tag(tag_name)
    MAIN = "main" + (f"-{suffix}" if suffix else "")
    DEB = "deb" + (f"-{suffix}" if suffix else "")
    intern_main = f"intern/{MAIN}"
    intern_deb = f"intern/{DEB}"
    if suffix:
        say(f"maintenance line: -b {suffix!r} -> {MAIN}/{DEB}")

    # Materialise the maintenance branches off their bare parents if they
    # don't yet exist locally. The intern counterparts are fetched above and
    # are expected to exist (a first run publishes them via debch).
    if suffix:
        for base, lined in (("main", MAIN), ("deb", DEB)):
            if not ref_exists(repo, f"refs/heads/{lined}"):
                say(f"creating local {lined} from {base}")
                try:
                    repo.git.branch(lined, base)
                except git.exc.GitError as exc:
                    die(f"could not create {lined} from {base}: "
                        f"{strip_giterror(str(exc))}")

    # Step 5: early-exit if deb already descends from target AND equals
    # intern/deb ------------------------------------------------------------
    # On a freshly-established maintenance line the intern counterpart may
    # not exist yet (debch publishes it on the first successful run); in
    # that case there is plainly work to do, so skip the equality check.
    say(f"step 5: checking whether {DEB} is already up to date")
    try:
        deb_head = get_ref_commit(repo, DEB)
    except KeyError:
        die(f"'{DEB}' branch does not exist")
    intern_deb_present = ref_exists(repo, intern_deb)
    if intern_deb_present:
        intern_deb_head = get_ref_commit(repo, intern_deb)
        if repo.is_ancestor(target_commit.hexsha, deb_head.hexsha) \
                and deb_head.hexsha == intern_deb_head.hexsha:
            say("nothing to do")
            teardown_worktree(repo, name, force=False)
            print("nothing to do")
            return
    else:
        say(f"{intern_deb} absent (first run on this line); proceeding")

    # There IS work to do: spin up (or reattach to) the staging worktree.
    # Creation is deferred to HERE so a nothing-to-do run never lays a finger
    # on /src/work. The worktree lives at /src/work/NAME-update.
    say("setting up update worktree")
    wt_repo, wt_path = ensure_worktree(repo, name)
    say(f"worktree at {wt_path}")

    # Steps 6-8: advance main ----------------------------------------------
    say(f"steps 6-8: advancing {MAIN}")
    prepare_update_branch(wt_repo, MAIN)
    if ref_exists(repo, intern_main):
        say(f"step 7: merging {intern_main}")
        safe_merge(wt_repo, intern_main, label=intern_main, no_ff=False)
    else:
        say(f"step 7: no {intern_main} yet; skipping intern merge")
    say(f"step 8: merging target {tag_name}")
    safe_merge(wt_repo, tag_name, label=f"target {tag_name}", no_ff=False)
    finish_target(wt_repo, orig_top, MAIN)

    # Steps 9-13: advance deb -----------------------------------------------
    say(f"steps 9-13: advancing {DEB}")
    prepare_update_branch(wt_repo, DEB)
    if ref_exists(repo, intern_deb):
        say(f"step 10: merging {intern_deb}")
        safe_merge(wt_repo, intern_deb, label=intern_deb, no_ff=True)
    else:
        say(f"step 10: no {intern_deb} yet; skipping intern merge")
    say(f"step 11: merging {MAIN}")
    merged_main = safe_merge(wt_repo, MAIN, label=MAIN)
    if not merged_main:
        # Nothing new flowed from main; main was already advanced and
        # uploaded above, but deb has nothing to absorb. Tear the staging
        # worktree down (it is clean -- the merge was a no-op) and report,
        # mirroring the early-exit semantics.
        say("nothing to do")
        teardown_worktree(repo, name, force=False)
        print("nothing to do")
        return

    # Step 12: update debian/changelog --------------------------------------
    # Compute VNR = <upstream version> "." <subversion>, with the
    # subversion N reset to 0 on an upstream change. The upstream version
    # is taken straight from the target tag (leading v/V stripped),
    # PRESERVING ITS NATURAL COMPONENT COUNT -- the doc's "A.B.C.N" is the
    # common 3-component case, but it also blesses date schemes as
    # "YYYY.MM.0" (2-component) and this very repo uses 2-component
    # upstream tags (1.524 -> 1.524.1). Padding everyone to 3 components
    # would corrupt those, so we simply append ".0" to the tagged version.
    core = strip_v_prefix(tag_name)
    parts = core.split(".")
    if all(p.isdigit() for p in parts):
        vnr = core + ".0"
    else:
        # Non-numeric / date-ish tag: derive the upstream portion from the
        # current changelog version by dropping its trailing subversion
        # component, then reset N to 0 (e.g. 1.524.1 -> 1.524.0).
        cur = subprocess.run(["dpkg-parsechangelog", "-S", "Version"],
                             cwd=wt_path, stdout=subprocess.PIPE, text=True)
        if cur.returncode != 0:
            die("cannot read current changelog version for VNR")
        m = re.match(r'^(.*)\.[0-9]+$', cur.stdout.strip())
        if not m:
            die(f"cannot derive VNR from non-numeric tag {tag_name!r}")
        vnr = m.group(1) + ".0"

    say(f"step 12: updating debian/changelog (VNR={vnr})")
    run_debchange(wt_path, vnr)

    # Fold the changelog edit into the merge commit (matches the documented
    # ``git commit --amend --no-edit -n debian/changelog``).
    try:
        wt_repo.git.commit("--amend", "--no-edit", "-n", "--no-verify",
                           "debian/changelog")
    except git.exc.GitError as exc:
        die(f"commit --amend failed: {strip_giterror(str(exc))}")

    # Publish deb before the destructive build step, so a debch crash
    # leaves the integration commit safely on the deb branch.
    finish_target(wt_repo, orig_top, DEB)

    # Step 13 (last actionable step): run debch -----------------------------
    # NOTE: per the assignment, the trailing "Analyze output, fix errors"
    # human step is intentionally omitted here. debch builds/uploads the
    # branch it is invoked on, so it must run in a checkout that currently
    # has *deb* checked out. After finish_target(DEB) that is either the
    # original dir (if it started on deb, advanced in place by git-merge-in)
    # or the worktree (where we fast-forwarded deb ourselves).
    say("step 13: running debch")
    deb_cwd = orig_top if current_branch(orig_top) == DEB else wt_path
    res = subprocess.run(["debch"], cwd=deb_cwd)
    if res.returncode != 0:
        die(f"debch failed (exit {res.returncode})")

    # Upload succeeded: tear down the staging worktree (forcing past any
    # disposable debch build artifacts) and sweep scratch branches.
    say("done")
    teardown_worktree(repo, name, force=True)
    print("OK")


if __name__ == "__main__":
    main()
