"""Per-app git repo: pristine `upstream` history + a local working branch.

Each installed app gets its own git repo at `/data/apps/<slug>/.git` with
two branches:

  - `upstream` holds the exact bytes of each installed manifest version.
    Only the installer commits here, via `record_upstream`. It is the
    merge base that lets an update tell "did the user diverge from what
    upstream shipped" without guessing from a sha.
  - `main` is the local working branch. The file watcher and the agent
    commit their edits here (via `commit_local`); it is the branch the
    working tree actually checks out.

Update is `record_upstream` (commit the new upstream bytes) then
`merge_upstream`, which computes a clean-vs-conflict verdict with
`git merge-tree --write-tree` WITHOUT touching the live working tree —
so a conflict never leaves the app served in a half-merged state. The
caller (install.py) applies a clean merge by writing the merged bytes
and recompiling; on conflict it surfaces the conflicting paths and
leaves the local edits intact for an agent to resolve later.

Only SOURCE is tracked — `index.jsx`, job scripts (`*.sh`), prompts,
seed templates. The compiled bundle (`/data/compiled/app-<id>.js`) is a
gitignored build artifact, and the integer-id storage tree
(`/data/apps/<id>/`) is a SEPARATE directory the mini-app writes at
runtime, not under this source dir at all. A committed `.gitignore`
keeps both out even if a future caller drops them here.

Why shell out to the container's `git` rather than pygit2: `git` 2.x is
already in the image, the operations are coarse-grained (one subprocess
per install/update, not a hot loop), and `merge-tree --write-tree` — the
primitive that makes the no-clobber verdict possible — is a porcelain we
get for free without a libgit2 binding to pin and maintain.

CONCURRENCY: callers MUST hold `fs_locks.source_dir_lock(<source_dir>)`
around every entry point here, so the watcher's commit-on-save can't race
the installer's merge on the same repo. This module does not take the
lock itself — the lock is keyed on the source dir, which only the caller
knows, and nesting lock acquisition inside would hide the ordering the
rest of install.py reasons about.

install.py drives this module for every app that has a source directory; an
app with no source_dir has no `.git`.
"""

from __future__ import annotations

import os
import subprocess
from dataclasses import dataclass, field
from pathlib import Path

# Branch names. `upstream` is installer-only pristine history; `main` is
# the local working branch the watcher/agent commit to and the working
# tree checks out.
UPSTREAM_BRANCH = "upstream"
LOCAL_BRANCH = "main"

# Files we never want in app source history regardless of who drops them
# in the source dir. The live compiled bundle is /data/compiled/app-<id>.js
# (outside this tree), so it never appears here. We must NOT blanket-ignore
# `*.js`: building-apps.md tells the agent to split larger apps into sibling
# `.js`/`.jsx`/`.ts`/`.tsx` modules (e.g. `cards.js`) imported from index.jsx,
# and those ARE hand-written source — a blanket `*.js` silently dropped them
# from per-app history, breaking the merge/conflict-resolution model for any
# modular app. So the ignore is scoped to genuine generated/vendored output
# (mirrors the watcher's recompile-ignore set) plus install artifacts and the
# integer-id storage tree.
_GITIGNORE = "\n".join([
  "# Generated build output and vendored deps are not hand-written source.",
  "dist/",
  ".build/",
  "node_modules/",
  "# Manifest static_assets are install-managed (re-fetched from the manifest,",
  "# listed in .mobius-static-assets.json), not edited source — keep prebuilt",
  "# bundles/binaries out of per-app history.",
  "static/",
  "# Install/rollback snapshots are not source.",
  "*.bak",
  "*.mobius-bak",
  "# init-cron.sh is install-managed (re-generated by the scaffold on every",
  "# update that declares a schedule, dropped by _drop_app_cron when one is",
  "# removed). It must NOT be tracked: tracking it lets a later merge-abort or",
  "# conflict hard-reset restore a dropped script, which the entrypoint boot",
  "# replay then re-arms as an orphan cron (card 099).",
  "init-cron.sh",
  "# Defensive: the integer-id storage tree is a sibling dir, but if a",
  "# numeric data dir ever lands here it is runtime data, not source.",
  "[0-9]*/",
  "",
])

# A fixed identity so commits don't depend on the container's global git
# config (which the mobius user may not have set). The installer commits
# under the upstream identity; local commits keep the same identity since
# the agent is the de-facto author of every local edit.
_GIT_NAME = "Mobius"
_GIT_EMAIL = "mobius@localhost"

# Subprocess timeout. App repos are tiny (one source file plus a couple
# of scripts), so any git op that runs longer than this is wedged, not
# slow.
_GIT_TIMEOUT = 30


@dataclass
class MergeResult:
  """Verdict of merging `upstream` into the local `main` branch.

  `status` is 'clean' when the three-way merge produced no conflicts and
  `merged_bytes` holds the merged `index.jsx` the caller should write and
  recompile. `status` is 'conflict' when at least one tracked file
  conflicted; `conflict_paths` names them and `merged_bytes` is None —
  the caller must NOT write anything, leaving the local edits intact for
  an agent to resolve.

  `merged_job` is the merged bytes of the schedule job script (when the
  caller passed `job_name` to `merge_upstream`), carried back so a locally
  edited `fetch.sh`/`build.sh` survives a clean update instead of being
  overwritten by the bundled copy. It is None when no `job_name` was
  requested, or when the job is not present in the merged tree.
  """
  status: str
  conflict_paths: list[str] = field(default_factory=list)
  merged_bytes: bytes | None = None
  merged_job: bytes | None = None


def _git_env(repo: Path | str) -> dict:
  """Isolated env for a per-app git op so it can never bleed into an
  enclosing repo. `/data` is itself a git repo (the agent's pm-commit
  history), so a source_dir with no dedicated `.git` would otherwise let
  git walk up to `/data` — making every per-app op operate on the wrong
  repo and report spurious conflicts. Pin GIT_CEILING_DIRECTORIES to the
  app-dir's parent (git won't search above it → it finds the app's OWN
  repo or none, never /data) and scrub inherited GIT_* pointers that would
  override `-C`. Used by EVERY git subprocess here (`_run` + the few direct
  bytes-on-stdin calls). Mirrors the test conftest's `_isolate_git_env`.
  """
  env = dict(os.environ)
  for var in (
    "GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE",
    "GIT_OBJECT_DIRECTORY", "GIT_COMMON_DIR", "GIT_NAMESPACE",
  ):
    env.pop(var, None)
  env["GIT_CEILING_DIRECTORIES"] = str(Path(repo).resolve().parent)
  return env


def _run(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess:
  """Runs `git -C <repo> <args>` with the fixed Mobius identity.

  The identity is injected per-invocation via `-c` rather than written
  into the repo config so the repo carries no state the next reader has
  to know about. `check=False` lets callers inspect a non-zero return
  (e.g. merge-tree signalling a conflict) instead of raising. Runs under
  the isolated `_git_env` so it can never bleed into the enclosing /data
  repo.
  """
  cmd = [
    "git",
    "-c", f"user.name={_GIT_NAME}",
    "-c", f"user.email={_GIT_EMAIL}",
    "-C", str(repo),
    *args,
  ]
  return subprocess.run(
    cmd, capture_output=True, text=True, timeout=_GIT_TIMEOUT,
    check=check, env=_git_env(repo),
  )


def _tracked_source(source_dir: Path) -> list[str]:
  """The source files to stage: everything in the dir minus what
  `.gitignore` excludes. We add by directory (`git add -A`) and let the
  committed `.gitignore` do the filtering, so a new job script the agent
  drops in is picked up without this module enumerating extensions."""
  return ["-A"]


def is_repo(source_dir: str | Path) -> bool:
  """Whether `source_dir` already holds a git repo."""
  return (Path(source_dir) / ".git").exists()


def head_sha(source_dir: str | Path, branch: str) -> str:
  """The commit sha at the tip of `branch` (e.g. the merge base an
  update will diverge from). Assumes the repo + branch exist."""
  return _run(Path(source_dir), "rev-parse", branch).stdout.strip()


def ensure_repo(source_dir: str | Path) -> None:
  """Initializes the per-app repo if absent; a no-op once it exists.

  Creates the repo, writes the `.gitignore`, and makes an empty root
  commit on `upstream`. `main` is branched from that same root and
  checked out as the working branch.

  The empty root is deliberate: a meaningful merge needs `main` to
  descend from the SAME `upstream` commit that recorded the version it
  was installed at, so that a later update's merge base is the
  previously-installed version (not an unrelated root). The install path
  establishes that by `record_upstream` + `align_local_to_upstream`; the
  empty root is just the shared seed both branches grow from. Idempotent:
  re-running on an existing repo does nothing.
  """
  repo = Path(source_dir)
  if is_repo(repo):
    return
  repo.mkdir(parents=True, exist_ok=True)
  _run(repo, "init", "-q", "-b", UPSTREAM_BRANCH)
  (repo / ".gitignore").write_text(_GITIGNORE, encoding="utf-8")
  _run(repo, "add", ".gitignore")
  _run(repo, "commit", "-q", "-m", "Initialize app repo", "--allow-empty")
  _run(repo, "branch", LOCAL_BRANCH, UPSTREAM_BRANCH)
  _run(repo, "checkout", "-q", LOCAL_BRANCH)


def align_local_to_upstream(source_dir: str | Path) -> None:
  """Resets the local `main` branch to the current `upstream` tip.

  Called at INSTALL so the working branch starts at exactly the
  installed version: `main` then descends from that upstream commit, and
  the next update's three-way merge has it as the shared base. Must NOT
  be called on update — that would discard the local edits this whole
  model exists to preserve.

  Updates the branch ref and checks the working tree out to it so the
  on-disk `index.jsx` matches what was just installed.
  """
  repo = Path(source_dir)
  up = head_sha(repo, UPSTREAM_BRANCH)
  _run(repo, "checkout", "-q", LOCAL_BRANCH)
  _run(repo, "reset", "-q", "--hard", up)


def record_upstream(
  source_dir: str | Path,
  entry_bytes: bytes,
  manifest_url: str,
  version: str,
  job_name: str | None = None,
  job_bytes: bytes | None = None,
) -> str:
  """Commits the pristine installed `index.jsx` bytes onto `upstream`.

  Writes `entry_bytes` to the upstream branch's `index.jsx` and commits
  it as the canonical "this is what version <version> shipped" snapshot,
  WITHOUT disturbing the checked-out `main` working tree. Returns the new
  upstream commit sha (the merge base a later update will diverge from).

  When both `job_name` and `job_bytes` are given, the schedule job script
  (e.g. `fetch.sh`) is committed into the same `upstream` tree alongside
  `index.jsx`, so a later update can three-way-merge a locally edited job
  script the same way it merges `index.jsx` — instead of the bundled copy
  silently clobbering the local edit.

  Implemented by staging the bytes into the index against the `upstream`
  tip and committing with an explicit parent, so the working tree (on
  `main`) is never checked out to `upstream` and back — the live
  `index.jsx` the watcher might be compiling stays put.
  """
  repo = Path(source_dir)
  ensure_repo(repo)
  parent = _run(repo, "rev-parse", UPSTREAM_BRANCH).stdout.strip()
  # Hash the pristine bytes into a blob. This needs bytes on stdin, which
  # `_run` (text-mode, no stdin) can't carry, so it's a direct subprocess
  # call. `--path index.jsx` lets git apply any path-based attributes.
  blob = subprocess.run(
    [
      "git", "-C", str(repo), "hash-object", "-w", "--stdin",
      "--path", "index.jsx",
    ],
    input=entry_bytes, capture_output=True, timeout=_GIT_TIMEOUT, check=True,
    env=_git_env(repo),
  ).stdout.decode().strip()
  # Build a tree that is the upstream tip's tree with index.jsx replaced
  # by the new blob, via a temporary index keyed to the upstream commit.
  _run(repo, "read-tree", UPSTREAM_BRANCH)
  subprocess.run(
    [
      "git", "-C", str(repo), "update-index", "--add", "--cacheinfo",
      f"100644,{blob},index.jsx",
    ],
    capture_output=True, text=True, timeout=_GIT_TIMEOUT, check=True,
    env=_git_env(repo),
  )
  # Stage the schedule job script alongside index.jsx so it lives in the
  # same upstream tree and a later update merges local job edits the same
  # way it merges index.jsx. Same bytes-on-stdin reason as the index.jsx
  # blob, so a direct subprocess call rather than `_run`.
  #
  # Job scripts are staged EXECUTABLE (100755), matching the mode the local
  # `main` branch records for them. cron runs the bare job path (the crontab
  # entry init-cron-scaffold.sh writes is `<job-path> [app-id]`, with no `bash`
  # prefix), so the script lives on disk with the exec bit, and commit_local's
  # plain `git add` under the default core.fileMode=true records 100755. Staging
  # the upstream copy at 100644 instead would leave a permanent 644-vs-755 skew
  # between `upstream` and `main` that the merge has to reconcile on every
  # update — and when the merge base predates job-script tracking (the job is an
  # add/add against an empty base), the mode mismatch alone makes git report a
  # spurious CONFLICT (add/add) even with identical bytes. Recording the same
  # 100755 keeps the modes equal so the only thing the merge sees is content.
  if job_name and job_bytes is not None:
    job_blob = subprocess.run(
      [
        "git", "-C", str(repo), "hash-object", "-w", "--stdin",
        "--path", job_name,
      ],
      input=job_bytes, capture_output=True, timeout=_GIT_TIMEOUT, check=True,
      env=_git_env(repo),
    ).stdout.decode().strip()
    subprocess.run(
      [
        "git", "-C", str(repo), "update-index", "--add", "--cacheinfo",
        f"100755,{job_blob},{job_name}",
      ],
      capture_output=True, text=True, timeout=_GIT_TIMEOUT, check=True,
      env=_git_env(repo),
    )
  tree = _run(repo, "write-tree").stdout.strip()
  msg = f"install v{version} from {manifest_url}"
  sha = _run(
    repo, "commit-tree", tree, "-p", parent, "-m", msg,
  ).stdout.strip()
  _run(repo, "update-ref", f"refs/heads/{UPSTREAM_BRANCH}", sha)
  # Restore the index to match the checked-out working branch so a later
  # `git status`/commit on `main` isn't confused by the upstream tree we
  # just staged into the shared index.
  _run(repo, "read-tree", LOCAL_BRANCH)
  return sha


def has_unresolved_conflicts(source_dir: str | Path) -> bool:
  """Whether an in-progress merge still has ANY tracked file unresolved.

  The invariant this guards: an update must NEVER finalize while conflict
  markers remain in any tracked file. Two signals, BOTH required, because
  they cover disjoint moments of the resolve flow:

  - `git ls-files -u` lists unmerged index entries. It is non-empty right
    after `start_conflict_merge` materializes the conflict, BUT it clears
    the instant the agent `git add`s a file — even one that still carries
    `<<<<<<<` markers (adding marks the path "resolved" in the index
    regardless of content).
  - A `git grep` for the LABELED boundary markers (`<<<<<<< <ref>` and
    `>>>>>>> <ref>`) still fires on a marker-bearing file the agent has
    already staged. We match only those two boundaries — NOT `git diff
    --check`, which flags any 7-char marker line including a bare `=======`.
    A legitimate `=======` separator (a heredoc divider, a setext rule) in
    resolved app content would otherwise read as an unresolved conflict
    forever and deadlock the update. The labeled boundaries that a real `git
    merge` always writes essentially never start a line of real source.

  Only meaningful while a merge is in progress: outside a merge there is
  nothing to finalize, so the caller is expected to check MERGE_HEAD first
  and this returns False (no unmerged entries, `--check` not run).
  """
  repo = Path(source_dir)
  if not (repo / ".git" / "MERGE_HEAD").exists():
    return False
  unmerged = _run(repo, "ls-files", "-u").stdout.strip()
  if unmerged:
    return True
  # Scan tracked content for the labeled conflict boundaries. `git grep`
  # exits 0 when a line matches, 1 when none do. We match only `<<<<<<< ` and
  # `>>>>>>> ` (7 chars + the space before the ref that `git merge` writes),
  # never the bare `=======` separator that `git diff --check` would flag on
  # legitimate content — see the docstring.
  markers = _run(repo, "grep", "-lE", r"^(<<<<<<< |>>>>>>> )", check=False)
  return markers.returncode == 0


def commit_local(source_dir: str | Path, msg: str) -> str | None:
  """Commits the current working-tree source onto `main`.

  Stages the source files (filtered by `.gitignore`) and commits them as
  a local edit. Returns the new commit sha, or None when there was
  nothing to commit (the tree already matches `main`'s tip) — a no-op
  rather than an empty commit so the history stays meaningful.

  HARD GATE on the merge-finalize path: if a merge is in progress
  (MERGE_HEAD present) and `has_unresolved_conflicts` is True, this REFUSES
  to commit and returns None, leaving the merge in progress. The invariant
  is that an update is never finalized while ANY tracked file still holds
  conflict markers — without this gate a conflict in a NON-entry file (a
  job script like `fetch.sh`) would sail through, because the only other
  resolution signal is "does index.jsx compile", which stays true. So no
  caller (watcher or install) can commit a marker-bearing tree as source.

  If a merge is in progress and FULLY resolved (no unmerged entries, no
  markers), the `git commit` here finalizes it as a 2-parent merge commit,
  so the upstream tip becomes an ancestor of `main` and the merge base
  advances for free.
  """
  repo = Path(source_dir)
  ensure_repo(repo)
  # Stage BEFORE the gate so `has_unresolved_conflicts` reads git's resolved
  # state: staging marks unmerged paths resolved in the index (clearing
  # `ls-files -u`) whether or not their content is clean, so the gate then
  # rests on the marker scan, which fires only when markers actually remain.
  # If we gated before staging, a conflict the agent resolved on disk but
  # never `git add`ed would still show as an unmerged index entry and we'd
  # wrongly refuse to finalize a clean resolution.
  _run(repo, "add", *_tracked_source(repo))
  if (repo / ".git" / "MERGE_HEAD").exists() and has_unresolved_conflicts(repo):
    return None
  status = _run(repo, "status", "--porcelain").stdout.strip()
  if not status:
    return None
  _run(repo, "commit", "-q", "-m", msg)
  return _run(repo, "rev-parse", LOCAL_BRANCH).stdout.strip()


def commit_merge(
  source_dir: str | Path, upstream_tip: str, msg: str,
) -> str | None:
  """Records the working-tree source onto `main` as a merge of upstream.

  Commits the current working tree (the just-applied merge result) with
  TWO parents: the previous `main` tip and `upstream_tip` (the upstream
  commit whose changes were merged in). The second parent is what makes
  the base advance — after this, `upstream_tip` is an ancestor of `main`,
  so the NEXT update's three-way merge diffs only the genuinely-new
  upstream delta against local edits instead of re-litigating the whole
  already-merged history against a stale base (the bug that turned every
  repeated update into a spurious conflict).

  A plain `commit_local` cannot do this: a single-parent commit leaves
  `upstream` unreachable from `main`, so `git merge-base` keeps returning
  the original install point. Returns the new commit sha, or None when
  there is nothing to record (working tree already matches `main` and
  `upstream_tip` is already an ancestor — e.g. a re-install of the same
  version with no local edits).
  """
  repo = Path(source_dir)
  ensure_repo(repo)
  _run(repo, "add", *_tracked_source(repo))
  tree = _run(repo, "write-tree").stdout.strip()
  main_tree = _run(repo, "rev-parse", f"{LOCAL_BRANCH}^{{tree}}").stdout.strip()
  already_merged = _run(
    repo, "merge-base", "--is-ancestor", upstream_tip, LOCAL_BRANCH,
    check=False,
  ).returncode == 0
  if tree == main_tree and already_merged:
    return None
  main_tip = _run(repo, "rev-parse", LOCAL_BRANCH).stdout.strip()
  sha = _run(
    repo, "commit-tree", tree, "-p", main_tip, "-p", upstream_tip, "-m", msg,
  ).stdout.strip()
  _run(repo, "update-ref", f"refs/heads/{LOCAL_BRANCH}", sha)
  return sha


def local_diverged_from(source_dir: str | Path, base_commit: str) -> bool:
  """Whether local `main` differs from `base_commit`.

  The caller uses the previous upstream commit as `base_commit`, before
  recording a new upstream version. Exit 0 means no local tree delta;
  non-zero means local edits exist or git could not prove equality.
  """
  proc = _run(
    Path(source_dir), "diff", "--quiet", base_commit, LOCAL_BRANCH,
    check=False,
  )
  return proc.returncode != 0


def merge_upstream(
  source_dir: str | Path, job_name: str | None = None,
) -> MergeResult:
  """Merges `upstream` into `main` and returns the verdict.

  Uses `git merge-tree --write-tree` to perform the three-way merge in
  memory: it neither moves `main` nor touches the working tree, so a
  conflict cannot leave the app served in a half-merged state. On a clean
  merge the merged `index.jsx` bytes are read out of the resulting tree
  and returned for the caller to write + recompile. When `job_name` is
  given, the merged job script bytes are read out of the same tree into
  `merged_job` (None when the job is not tracked in the tree) so a locally
  edited job script flows through the merge like `index.jsx`. On conflict
  the conflicting paths are returned and `merged_bytes` is None — the
  caller leaves the local edits untouched.

  The caller is responsible for advancing `main` to the merged tree (by
  writing `merged_bytes` and letting the watcher/`commit_local` commit
  it). We deliberately do NOT fast-forward `main` here: the merged source
  has to be recompiled and committed as one transactional unit on the
  caller's side, and moving the branch before that would briefly point
  `main` at a tree the compiled bundle doesn't match.
  """
  repo = Path(source_dir)
  ensure_repo(repo)
  proc = _run(
    repo, "merge-tree", "--write-tree", "--name-only",
    LOCAL_BRANCH, UPSTREAM_BRANCH,
    check=False,
  )
  # merge-tree exits 0 on a clean merge, 1 on conflicts, >1 on real
  # errors. stdout's first line is always the merged tree oid. On a
  # conflict the `--name-only` section follows: the conflicting file
  # paths are the lines AFTER the oid up to the first blank line; the
  # lines after that blank are human-readable "CONFLICT (...)" messages,
  # not paths.
  if proc.returncode > 1:
    raise RuntimeError(
      f"git merge-tree failed (rc={proc.returncode}): {proc.stderr.strip()}"
    )
  lines = proc.stdout.splitlines()
  tree_oid = lines[0].strip() if lines else ""
  if proc.returncode == 1:
    conflict_paths: list[str] = []
    for ln in lines[1:]:
      if not ln.strip():
        break  # blank line ends the path section
      conflict_paths.append(ln.strip())
    return MergeResult(status="conflict", conflict_paths=conflict_paths)
  # Read the merged blob in BINARY mode (not via `_run`, which is text)
  # so the bytes the caller compiles + writes are byte-faithful — no
  # encode round-trip or line-ending normalization.
  merged = subprocess.run(
    ["git", "-C", str(repo), "cat-file", "-p", f"{tree_oid}:index.jsx"],
    capture_output=True, timeout=_GIT_TIMEOUT, check=False,
    env=_git_env(repo),
  )
  merged_bytes = merged.stdout if merged.returncode == 0 else None
  merged_job: bytes | None = None
  if job_name:
    # Read the merged job script in BINARY mode, same as index.jsx. rc != 0
    # means the job isn't present in the merged tree (e.g. a tree recorded
    # before job scripts were tracked) — leave merged_job None and let the
    # caller fall back to the bundled job.
    job = subprocess.run(
      ["git", "-C", str(repo), "cat-file", "blob", f"{tree_oid}:{job_name}"],
      capture_output=True, timeout=_GIT_TIMEOUT, check=False,
      env=_git_env(repo),
    )
    merged_job = job.stdout if job.returncode == 0 else None
  return MergeResult(
    status="clean", merged_bytes=merged_bytes, merged_job=merged_job,
  )


def start_conflict_merge(source_dir: str | Path) -> list[str]:
  """Starts a REAL merge of `upstream` into the working tree on `main`.

  Unlike `merge_upstream` (an in-memory verdict that never touches the tree),
  this MUTATES the working tree: `git merge --no-commit --no-ff upstream`
  writes conflict markers into the conflicting files and records `MERGE_HEAD`,
  leaving exactly the state a human's `git pull` would — so the agent resolves
  it with ordinary git. The caller must NOT recompile afterward: the
  marker-bearing source won't compile, so the file watcher keeps serving the
  prior good bundle until the agent resolves the markers and finishes the merge
  (`git add` + `git commit` / `git merge --continue`), at which point
  `commit_local` finalizes the 2-parent merge and the base advances.

  To back out, the agent runs `git merge --abort`, restoring `main` to its
  pre-merge (committed local) state. Call only after `merge_upstream` verdicted
  a conflict, and under `source_dir_lock`.

  Returns the conflicting repo-relative paths. In the pathological case where
  the real merge resolves clean (it shares ort machinery with `merge_upstream`,
  so they agree in practice), the in-progress merge is aborted and an empty list
  is returned so the caller never strands a half-merge.
  """
  repo = Path(source_dir)
  ensure_repo(repo)
  _run(repo, "merge", "--no-commit", "--no-ff", UPSTREAM_BRANCH, check=False)
  # Unmerged paths show in `git status --porcelain` with a U in either status
  # column (UU/AU/UA/UD/DU) or the AA/DD both-added/both-deleted codes.
  status = _run(repo, "status", "--porcelain").stdout
  conflict_paths: list[str] = []
  for line in status.splitlines():
    xy = line[:2]
    if "U" in xy or xy in ("AA", "DD"):
      conflict_paths.append(line[3:].strip())
  if not conflict_paths and (repo / ".git" / "MERGE_HEAD").exists():
    # Real merge resolved clean (vs the conflict verdict). Don't strand a
    # dangling --no-commit merge; abort back to the committed local state.
    _run(repo, "merge", "--abort", check=False)
  return conflict_paths


def abort_in_progress_merge(source_dir: str | Path) -> bool:
  """Abort a half-finished merge left by a prior unresolved conflict.

  When a `start_conflict_merge` was never resolved (MERGE_HEAD still set,
  markers on disk) and a NEW update arrives, the caller must abort the stale
  merge before committing local edits — otherwise the markers get committed as
  source. `git merge --abort` restores `main`'s working tree to its pre-merge
  (committed local) state. Returns True if a merge was aborted, else False.
  """
  repo = Path(source_dir)
  if not is_repo(repo) or not (repo / ".git" / "MERGE_HEAD").exists():
    return False
  _run(repo, "merge", "--abort", check=False)
  return True


# Smells
# - record_upstream stages the new blob into the SHARED index (read-tree
#   upstream -> update-index -> write-tree) then read-tree's it back to
#   `main`. That borrows the working index for a write-tree, which is safe
#   only because the caller holds source_dir_lock so the watcher can't be
#   committing on `main` concurrently. The lock contract is documented at
#   the module top; flagged here because the index-borrow is the spot that
#   relies on it. A conflict-free alternative is a bare temp index
#   (GIT_INDEX_FILE), deferred until a non-locked caller needs it.
# - The schedule job script now flows through record_upstream + merge_upstream
#   like index.jsx: record_upstream commits it into the `upstream` tree (when
#   the caller passes job_name + job_bytes), and merge_upstream reads the merged
#   job back into MergeResult.merged_job (when the caller passes job_name). So a
#   locally edited fetch.sh/build.sh survives a clean update instead of being
#   clobbered by the bundled copy — the gap the old note flagged is closed.
