#!/usr/bin/env bash
# land — trunk-based direct-push landing, portable across every repo.
# Rebase HEAD onto the remote default branch, run a fast secret-scan floor,
# push straight to the default branch. Retries automatically on a concurrent
# push race. No PR, no queue, no merge. Never --force, never --no-verify.
#
# Usage:  land [--main <branch>] [--remote <name>] [--dry-run] [-h|--help]
# Run from any worktree that has commits to land.
set -euo pipefail

MAX_ATTEMPTS=5
REMOTE=""
MAIN=""
DRY_RUN=0

die() {
  echo "land: ERROR: $1" >&2
  [[ -n "${2:-}" ]] && echo "land: next: $2" >&2
  exit 1
}

while [[ $# -gt 0 ]]; do
  case "$1" in
    --main)   MAIN="${2:?--main needs a branch}"; shift 2 ;;
    --remote) REMOTE="${2:?--remote needs a name}"; shift 2 ;;
    --dry-run) DRY_RUN=1; shift ;;
    -h|--help)
      sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'
      exit 0 ;;
    *) die "unknown argument: $1" "run: land --help" ;;
  esac
done

git rev-parse --is-inside-work-tree >/dev/null 2>&1 || die "not inside a git repository" "cd into your repo, then re-run land"

# --- resolve remote ---------------------------------------------------------
if [[ -z "$REMOTE" ]]; then
  if git remote | grep -qx origin; then
    REMOTE=origin
  else
    mapfile -t _remotes < <(git remote)
    [[ ${#_remotes[@]} -eq 1 ]] || die "cannot pick a remote (found: ${_remotes[*]:-none})" "pass --remote <name>"
    REMOTE="${_remotes[0]}"
  fi
fi

# --- resolve default branch (local symref -> remote symref -> main/master) ---
if [[ -z "$MAIN" ]]; then
  MAIN="$(git symbolic-ref --short "refs/remotes/$REMOTE/HEAD" 2>/dev/null | sed "s#^$REMOTE/##" || true)"
fi
if [[ -z "$MAIN" ]]; then
  MAIN="$(git ls-remote --symref "$REMOTE" HEAD 2>/dev/null | awk '/^ref:/{sub("refs/heads/","",$2); print $2; exit}' || true)"
fi
if [[ -z "$MAIN" ]]; then
  for b in main master; do
    if git rev-parse --verify --quiet "refs/remotes/$REMOTE/$b" >/dev/null; then MAIN="$b"; break; fi
  done
fi
[[ -n "$MAIN" ]] || die "cannot determine the default branch on '$REMOTE'" "pass --main <branch>"

echo "land: remote=$REMOTE branch=$MAIN"

# --- clean working tree gate ------------------------------------------------
# Only uncommitted changes to TRACKED files block a land: they would be lost in
# the rebase and are never what you meant to ship. Untracked files never enter
# the push and don't threaten the rebase, so they are tolerated (every real repo
# carries local junk — reports, scratch dirs, env files).
status="$(git status --porcelain --untracked-files=no)"
if [[ -n "$status" ]]; then
  echo "land: ERROR: tracked files have uncommitted changes:" >&2
  echo "$status" >&2
  die "uncommitted changes to tracked files" "commit or stash them, then re-run land"
fi

# --- something to land? -----------------------------------------------------
git fetch "$REMOTE" "$MAIN" --quiet
if [[ "$(git rev-list --count "$REMOTE/$MAIN"..HEAD)" -eq 0 ]]; then
  die "HEAD has no commits ahead of $REMOTE/$MAIN — nothing to land" "commit your work, then re-run land"
fi

# --- fast secret-scan floor (independent of any repo hook) ------------------
secret_scan() {
  local range="$1"
  if ! command -v gitleaks >/dev/null 2>&1; then
    echo "land: WARN: gitleaks not installed — secret-scan floor skipped." >&2
    echo "land: WARN: install gitleaks so pushes are secret-scanned. Continuing." >&2
    return
  fi
  # --exit-code 7 makes "leak found" distinguishable from gitleaks' own error exits.
  local rc=0
  gitleaks git --no-banner --redact --exit-code 7 --timeout 15 "--log-opts=$range" || rc=$?
  case "$rc" in
    0) : ;;
    7) die "gitleaks found a secret in $range" "remove the secret (rewrite the commit), then re-run land" ;;
    *) die "gitleaks failed to run (exit $rc) — cannot verify the push is secret-free" "fix the gitleaks error above, then re-run land" ;;
  esac
}

# --- rebase / scan / push loop ---------------------------------------------
for attempt in $(seq 1 "$MAX_ATTEMPTS"); do
  echo "land: attempt $attempt/$MAX_ATTEMPTS"
  git fetch "$REMOTE" "$MAIN" --quiet

  if ! git rebase "$REMOTE/$MAIN"; then
    mapfile -t conflicts < <(git diff --name-only --diff-filter=U 2>/dev/null || true)
    git rebase --abort
    echo "land: ERROR: rebase onto $REMOTE/$MAIN hit conflicts:" >&2
    printf '  %s\n' "${conflicts[@]:-<unknown>}" >&2
    die "conflicts require a human/agent decision (never auto-resolved)" "resolve the files above, commit, then re-run land"
  fi

  secret_scan "$REMOTE/$MAIN..HEAD"

  if [[ "$DRY_RUN" -eq 1 ]]; then
    echo "land: dry-run OK — would push $(git rev-parse --short HEAD) to $REMOTE/$MAIN"
    exit 0
  fi

  push_status=0
  push_output="$(git push "$REMOTE" "HEAD:refs/heads/$MAIN" 2>&1)" || push_status=$?

  if [[ "$push_status" -eq 0 ]]; then
    echo "land: landed $(git rev-parse HEAD) -> $REMOTE/$MAIN"
    exit 0
  fi

  # A genuine fast-forward race always carries git's own "(non-fast-forward)" or
  # "(fetch first)" parenthetical. A bare "rejected" also appears in pre-push hook
  # / branch-protection denials — those are policy, not races, so do NOT retry them:
  # surface the message and stop.
  if echo "$push_output" | grep -qiE 'non-fast-forward|fetch first|stale info'; then
    echo "land: push raced with a concurrent land; re-syncing and retrying..." >&2
    continue
  fi

  echo "$push_output" >&2
  die "git push to $REMOTE/$MAIN failed (exit $push_status)" "fix the error above, then re-run land"
done

die "failed to land after $MAX_ATTEMPTS attempts (sustained concurrent pushes)" "wait for other landers to finish, then re-run land"
