'use strict';
const { execFile, spawn } = require('child_process');
const path = require('path');

const DEFAULT_GIT_TIMEOUT_MS = _positiveInt(process.env.CTM_GIT_TIMEOUT_MS, 15000);
const DEFAULT_WORKTREE_CREATE_TIMEOUT_MS = Math.max(
  DEFAULT_GIT_TIMEOUT_MS,
  _positiveInt(process.env.CTM_WORKTREE_CREATE_TIMEOUT_MS, 120000)
);
const WORKTREE_CLEANUP_TIMEOUT_MS = Math.max(
  DEFAULT_GIT_TIMEOUT_MS,
  _positiveInt(process.env.CTM_WORKTREE_CLEANUP_TIMEOUT_MS, 60000)
);
const GIT_FORCE_KILL_DELAY_MS = 2500;

function _positiveInt(value, fallback) {
  const n = Number(value);
  return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
}

function _normalizeGitOptions(maxBuffer, options) {
  if (maxBuffer && typeof maxBuffer === 'object') {
    options = options || {};
    return {
      maxBuffer: maxBuffer.maxBuffer || 1024 * 1024 * 5,
      timeoutMs: _positiveInt(maxBuffer.timeoutMs ?? options.timeoutMs, DEFAULT_GIT_TIMEOUT_MS),
      operation: maxBuffer.operation || options.operation || '',
      timeoutCode: maxBuffer.timeoutCode || options.timeoutCode || 'GIT_COMMAND_TIMEOUT',
      cancelCode: maxBuffer.cancelCode || options.cancelCode || 'GIT_COMMAND_CANCELLED',
      allowStdoutOnError: !!(maxBuffer.allowStdoutOnError || options.allowStdoutOnError),
      signal: maxBuffer.signal || options.signal || null,
      onProgress: typeof maxBuffer.onProgress === 'function' ? maxBuffer.onProgress : options.onProgress,
      heartbeatMs: _positiveInt(maxBuffer.heartbeatMs ?? options.heartbeatMs, 1000),
    };
  }
  options = options || {};
  return {
    maxBuffer: maxBuffer || 1024 * 1024 * 5,
    timeoutMs: _positiveInt(options.timeoutMs, DEFAULT_GIT_TIMEOUT_MS),
    operation: options.operation || '',
    timeoutCode: options.timeoutCode || 'GIT_COMMAND_TIMEOUT',
    cancelCode: options.cancelCode || 'GIT_COMMAND_CANCELLED',
    allowStdoutOnError: !!options.allowStdoutOnError,
    signal: options.signal || null,
    onProgress: typeof options.onProgress === 'function' ? options.onProgress : null,
    heartbeatMs: _positiveInt(options.heartbeatMs, 1000),
  };
}

function _isExecTimeout(err) {
  if (!err) return false;
  if (err.timedOut === true) return true;
  return err.killed === true
    || err.code === 'ETIMEDOUT'
    || err.signal === 'SIGTERM';
}

function _isExecCancelled(err) {
  if (!err) return false;
  return err.cancelled === true || err.name === 'AbortError';
}

function _gitCommandForMessage(args) {
  return `git ${args.map(String).join(' ')}`;
}

function _wrapGitError(err, args, opts, stdout, stderr) {
  const cancelled = _isExecCancelled(err);
  const timedOut = !cancelled && _isExecTimeout(err);
  const command = _gitCommandForMessage(args);
  const rawMessage = String(stderr || stdout || err?.message || 'git command failed').trim();
  const message = cancelled
    ? `${opts.operation || command} was cancelled`
    : timedOut
    ? `${opts.operation || command} timed out after ${opts.timeoutMs}ms`
    : (rawMessage || `${command} failed`);
  const wrapped = new Error(message);
  wrapped.code = cancelled ? opts.cancelCode : timedOut ? opts.timeoutCode : err?.code;
  wrapped.originalCode = err?.code;
  wrapped.signal = err?.signal;
  wrapped.killed = err?.killed === true;
  wrapped.timedOut = timedOut;
  wrapped.cancelled = cancelled;
  wrapped.timeoutMs = opts.timeoutMs;
  wrapped.gitArgs = args.slice();
  wrapped.gitCommand = command;
  wrapped.stdout = stdout || '';
  wrapped.stderr = stderr || '';
  return wrapped;
}

function _emitGitProgress(onProgress, event) {
  if (typeof onProgress !== 'function') return;
  try {
    onProgress(event);
  } catch (_) {}
}

function _runGitSpawn(cwd, args, maxBuffer, options) {
  const opts = _normalizeGitOptions(maxBuffer, options);
  const command = _gitCommandForMessage(args);
  return new Promise((resolve, reject) => {
    const startedAt = Date.now();
    let child = null;
    let stdout = '';
    let stderr = '';
    let stdoutBytes = 0;
    let stderrBytes = 0;
    let lastOutput = '';
    let lastOutputAt = startedAt;
    let settled = false;
    let timedOut = false;
    let cancelled = false;
    let bufferOverflow = false;
    let timeoutTimer = null;
    let heartbeatTimer = null;
    let forceKillTimer = null;

    function elapsedMs() {
      return Date.now() - startedAt;
    }

    function progress(extra) {
      _emitGitProgress(opts.onProgress, {
        type: 'heartbeat',
        status: cancelled ? 'cancelling' : 'running',
        operation: opts.operation || command,
        gitCommand: command,
        gitArgs: args.slice(),
        pid: child && child.pid,
        startedAt,
        elapsedMs: elapsedMs(),
        quietMs: Date.now() - lastOutputAt,
        lastOutputAt,
        lastOutput,
        ...(extra || {}),
      });
    }

    function cleanupListeners() {
      if (timeoutTimer) clearTimeout(timeoutTimer);
      if (heartbeatTimer) clearInterval(heartbeatTimer);
      if (forceKillTimer) clearTimeout(forceKillTimer);
      if (opts.signal && abortListener) {
        try { opts.signal.removeEventListener('abort', abortListener); } catch (_) {}
      }
    }

    function finish(err) {
      if (settled) return;
      settled = true;
      cleanupListeners();
      if (err) reject(err);
      else resolve(stdout);
    }

    function killChild(reason) {
      if (!child || child.killed) return;
      progress({
        type: reason || 'kill',
        status: cancelled ? 'cancelling' : timedOut ? 'timeout' : 'running',
        message: cancelled ? 'Cancel requested; stopping git checkout.' : timedOut ? 'Checkout exceeded the safety timeout; stopping git.' : 'Stopping git.',
      });
      try { child.kill('SIGTERM'); } catch (_) {}
      forceKillTimer = setTimeout(() => {
        if (!settled && child && !child.killed) {
          try { child.kill('SIGKILL'); } catch (_) {}
        }
      }, GIT_FORCE_KILL_DELAY_MS);
    }

    const abortListener = () => {
      cancelled = true;
      killChild('cancel');
    };

    if (opts.signal && opts.signal.aborted) {
      const err = _wrapGitError({ cancelled: true }, args, opts, stdout, stderr);
      return finish(err);
    }

    try {
      child = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
    } catch (err) {
      return finish(_wrapGitError(err, args, opts, stdout, stderr));
    }

    if (opts.signal) {
      try { opts.signal.addEventListener('abort', abortListener, { once: true }); } catch (_) {}
    }

    progress({ type: 'start', message: `Started ${opts.operation || command}` });

    function append(stream, chunk) {
      if (settled) return;
      const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk || '');
      const bytes = Buffer.byteLength(text);
      if (stream === 'stdout') {
        stdout += text;
        stdoutBytes += bytes;
      } else {
        stderr += text;
        stderrBytes += bytes;
      }
      const trimmed = text.trim();
      if (trimmed) lastOutput = trimmed.split(/\r?\n/).slice(-1)[0].slice(0, 1000);
      lastOutputAt = Date.now();
      progress({ type: stream, stream, text: trimmed.slice(0, 2000), lastOutput });
      if (stdoutBytes + stderrBytes > opts.maxBuffer) {
        bufferOverflow = true;
        killChild('maxBuffer');
      }
    }

    child.stdout.on('data', chunk => append('stdout', chunk));
    child.stderr.on('data', chunk => append('stderr', chunk));
    child.on('error', err => finish(_wrapGitError(err, args, opts, stdout, stderr)));
    child.on('close', (code, signal) => {
      if (bufferOverflow) {
        const err = new Error(`${opts.operation || command} exceeded output buffer`);
        err.code = 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER';
        err.signal = signal;
        finish(_wrapGitError(err, args, opts, stdout, stderr));
        return;
      }
      if (cancelled) {
        const err = _wrapGitError({ cancelled: true, signal, killed: true }, args, opts, stdout, stderr);
        finish(err);
        return;
      }
      if (timedOut) {
        const err = _wrapGitError({ timedOut: true, signal, killed: true }, args, opts, stdout, stderr);
        finish(err);
        return;
      }
      if (code !== 0) {
        const err = new Error(stderr || stdout || `${command} exited with code ${code}`);
        err.code = code;
        err.signal = signal;
        finish(_wrapGitError(err, args, opts, stdout, stderr));
        return;
      }
      progress({ type: 'complete', status: 'succeeded', message: `${opts.operation || command} completed` });
      finish();
    });

    if (opts.timeoutMs > 0) {
      timeoutTimer = setTimeout(() => {
        timedOut = true;
        killChild('timeout');
      }, opts.timeoutMs);
    }
    if (opts.heartbeatMs > 0) {
      heartbeatTimer = setInterval(() => {
        if (!settled) progress({ type: 'heartbeat' });
      }, opts.heartbeatMs);
      if (heartbeatTimer.unref) heartbeatTimer.unref();
    }
  });
}

function _runGit(cwd, args, maxBuffer, options) {
  const opts = _normalizeGitOptions(maxBuffer, options);
  return new Promise((resolve, reject) => {
    execFile('git', args, { cwd, maxBuffer: opts.maxBuffer, timeout: opts.timeoutMs }, (err, stdout, stderr) => {
      if (!err) return resolve(stdout);
      const wrapped = _wrapGitError(err, args, opts, stdout, stderr);
      if (opts.allowStdoutOnError && stdout && !wrapped.timedOut) return resolve(stdout);
      reject(wrapped);
    });
  });
}

function _worktreeNamespace(options) {
  const raw = typeof options === 'string'
    ? options
    : (options && (options.namespace || options.agentType || options.owner)) || '';
  const normalized = String(raw || '').toLowerCase();
  return normalized === 'walle' || normalized === 'wall-e' ? 'walle' : 'claude';
}

function _worktreeParentDir(cwd, options) {
  const namespace = _worktreeNamespace(options);
  return path.join(cwd, namespace === 'walle' ? '.walle' : '.claude', 'worktrees');
}

// Run a git command in a given project directory
function git(cwd, args, maxBuffer = 1024 * 1024 * 5, options) {
  return _runGit(cwd, args, maxBuffer, { ...(options || {}), allowStdoutOnError: true });
}

function gitStrict(cwd, args, maxBuffer = 1024 * 1024 * 5, options) {
  return _runGit(cwd, args, maxBuffer, options);
}

function gitExitCode(cwd, args, maxBuffer = 1024 * 1024 * 5) {
  return new Promise((resolve) => {
    const opts = _normalizeGitOptions(maxBuffer, {});
    execFile('git', args, { cwd, maxBuffer: opts.maxBuffer, timeout: opts.timeoutMs }, (err) => {
      resolve(err ? (_isExecTimeout(err) ? 124 : Number(err.code || 1)) : 0);
    });
  });
}

// Get list of recent commits for base-ref picker
async function getCommits(cwd, count = 30) {
  const out = await git(cwd, ['log', '--oneline', '--no-decorate', `-${count}`]);
  return out.trim().split('\n').filter(Boolean).map(line => {
    const i = line.indexOf(' ');
    return { sha: line.slice(0, i), message: line.slice(i + 1) };
  });
}

// Get rich commit log for display (author, date, message body, parent)
async function getCommitLog(cwd, count = 20) {
  const FSEP = '<<|>>';  // field separator
  const RSEP = '<<||>>';  // record separator
  const FMT = [
    '%H', '%h', '%an', '%ae', '%ai',
    '%cn', '%ce', '%ci', '%P', '%D', '%s',
  ].join(FSEP) + RSEP;
  // Get structured fields + body separately (body can contain any chars)
  const out = await git(cwd, ['log', `--format=${FMT}`, `-${count}`]);
  // Get bodies via a second pass with %b delimited
  const bodyOut = await git(cwd, ['log', `--format=%b${RSEP}`, `-${count}`]);
  const bodies = bodyOut.split(RSEP).map(b => b.trim());
  const commits = [];
  const records = out.split(RSEP);
  for (let i = 0; i < records.length; i++) {
    const fields = records[i].trim().split(FSEP);
    if (fields.length < 11) continue;
    commits.push({
      sha: fields[0],
      shortSha: fields[1],
      author: fields[2],
      authorEmail: fields[3],
      authorDate: fields[4],
      committer: fields[5],
      committerEmail: fields[6],
      committerDate: fields[7],
      parents: fields[8] ? fields[8].split(' ').filter(Boolean) : [],
      refs: fields[9] || '',
      subject: fields[10],
      body: bodies[i] || '',
    });
  }
  return commits;
}

// Get current branch name
async function getBranch(cwd) {
  const out = await git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
  return out.trim();
}

function parseNumstat(out) {
  const files = [];
  for (const line of out.trim().split('\n')) {
    const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
    if (m) {
      files.push({
        additions: m[1] === '-' ? 0 : parseInt(m[1]),
        deletions: m[2] === '-' ? 0 : parseInt(m[2]),
        path: m[3],
      });
    }
  }
  return files;
}

// Get diff stat (file list with additions/deletions) - lightweight for badge polling
async function getDiffStat(cwd, baseRef) {
  let args;
  if (!baseRef) {
    args = ['diff', '--relative', '--stat', '--numstat'];
  } else if (/^[0-9a-f]{7,40}$/.test(baseRef)) {
    try {
      const out = await git(cwd, ['diff', '--stat', '--numstat', `${baseRef}^`, baseRef]);
      return parseNumstat(out);
    } catch {
      const out = await git(cwd, ['diff-tree', '--stat', '--numstat', '--root', baseRef]);
      return parseNumstat(out);
    }
  } else {
    args = ['diff', '--relative', '--stat', '--numstat', baseRef];
  }
  const out = await git(cwd, args);
  return parseNumstat(out);
}

// Get full unified diff - the source of truth for review
async function getFullDiff(cwd, baseRef) {
  let args;
  if (!baseRef) {
    // No base: working tree vs HEAD
    args = ['diff', '--relative', '-U5'];
  } else if (/^[0-9a-f]{7,40}$/.test(baseRef)) {
    // Commit SHA: show what that commit introduced (parent..commit)
    // No --relative here: show all files the commit touched regardless of cwd
    try {
      const raw = await git(cwd, ['diff', '-U5', `${baseRef}^`, baseRef]);
      return parseDiff(raw);
    } catch {
      // Root commit — use diff-tree to show all changes
      const raw = await git(cwd, ['diff-tree', '-p', '-U5', '--root', baseRef]);
      return parseDiff(raw);
    }
  } else {
    // Branch name or other ref: working tree vs ref
    args = ['diff', '--relative', '-U5', baseRef];
  }
  const raw = await git(cwd, args);
  return parseDiff(raw);
}

// Get diff for staged files only
async function getStagedDiff(cwd) {
  const raw = await git(cwd, ['diff', '--relative', '--staged', '-U5']);
  return parseDiff(raw);
}

// --- Stable per-repo git fact cache --------------------------------------
// resolveMainBranch / getDefaultReviewBase / getDefaultDiffStat each spawn
// 3-7 git subprocesses. A CPU profile of the live primary showed ~8.4s of
// synchronous spawn() fork/exec prefix on the main loop over ~4 min — fanned
// out by the Review tab/chooser polling these on demand AND the 30s
// file-change sweep calling getDefaultDiffStat per project. The underlying
// facts (main branch, merge-base, diff stat) are stable for seconds, so a
// short per-cwd TTL cache collapses the fan-out. The IN-FLIGHT promise is
// cached (not just the resolved value) so a burst of concurrent callers for
// the same repo shares ONE git invocation. Disable with CTM_GIT_CACHE=0.
const _GIT_CACHE_ENABLED = process.env.CTM_GIT_CACHE !== '0';
function _ttlPromiseCache(ttlMs) {
  const m = new Map(); // key -> { at, p }
  const get = (key, producer) => {
    if (!_GIT_CACHE_ENABLED || ttlMs <= 0) return Promise.resolve().then(producer);
    const now = Date.now();
    const e = m.get(key);
    if (e && (now - e.at) < ttlMs) return e.p;
    const p = Promise.resolve().then(producer);
    m.set(key, { at: now, p });
    // Drop the entry if the producer rejects so the next call retries instead
    // of pinning a failed result for the whole TTL window.
    p.catch(() => { const cur = m.get(key); if (cur && cur.p === p) m.delete(key); });
    return p;
  };
  get.clear = () => m.clear();
  return get;
}
const _MAIN_BRANCH_TTL_MS = Math.max(0, Number(process.env.CTM_GIT_MAIN_BRANCH_TTL_MS) || 60000);
const _REVIEW_BASE_TTL_MS = Math.max(0, Number(process.env.CTM_GIT_REVIEW_BASE_TTL_MS) || 5000);
const _DIFF_STAT_TTL_MS = Math.max(0, Number(process.env.CTM_GIT_DIFF_STAT_TTL_MS) || 3000);
const _mainBranchCache = _ttlPromiseCache(_MAIN_BRANCH_TTL_MS);
const _reviewBaseCache = _ttlPromiseCache(_REVIEW_BASE_TTL_MS);
const _diffStatCache = _ttlPromiseCache(_DIFF_STAT_TTL_MS);
// Drop cached facts (all repos) after an operation that changes branch/HEAD/
// working tree so the next read reflects it immediately.
function _invalidateGitFactCache() {
  _mainBranchCache.clear(); _reviewBaseCache.clear(); _diffStatCache.clear();
}

// Resolve the repo's main branch: origin/HEAD target, else local main, else master.
async function resolveMainBranch(cwd) {
  return _mainBranchCache(cwd || '', () => _resolveMainBranchUncached(cwd));
}
async function _resolveMainBranchUncached(cwd) {
  const originHead = await _gitSafe(cwd, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD']);
  if (originHead) {
    const b = originHead.replace(/^origin\//, '').trim();
    if (b) return b;
  }
  for (const cand of ['main', 'master']) {
    if (await _gitSafe(cwd, ['rev-parse', '--verify', '--quiet', cand])) return cand;
  }
  return 'main';
}

// Decide the default review base for a project (the PR-style "all branch work" model):
//   - on a branch ahead of / diverged from main → diff working tree vs merge-base(main, HEAD)
//   - on main / detached / no main → all uncommitted work (vs HEAD)
// Returns { kind:'vs-main'|'uncommitted', sentinel, ref, mainBranch, mergeBase }.
// `sentinel` is the value the client puts in crState.baseRef; `ref` is the concrete git ref.
async function getDefaultReviewBase(cwd) {
  return _reviewBaseCache(cwd || '', () => _getDefaultReviewBaseUncached(cwd));
}
async function _getDefaultReviewBaseUncached(cwd) {
  const mainBranch = await resolveMainBranch(cwd);
  const head = await _gitSafe(cwd, ['rev-parse', 'HEAD']);
  const mainSha = await _gitSafe(cwd, ['rev-parse', '--verify', '--quiet', mainBranch]);
  if (head && mainSha && head !== mainSha) {
    const mergeBase = await _gitSafe(cwd, ['merge-base', mainBranch, 'HEAD']);
    if (mergeBase && mergeBase !== head) {
      return { kind: 'vs-main', sentinel: '--vs-main', ref: mergeBase, mainBranch, mergeBase };
    }
  }
  return { kind: 'uncommitted', sentinel: '--uncommitted', ref: 'HEAD', mainBranch, mergeBase: null };
}

// Working tree vs an arbitrary ref (merge-base SHA or HEAD) — includes committed +
// staged + unstaged relative to that ref. Distinct from getFullDiff's commit mode,
// which shows a single commit's parent..commit diff.
async function getDiffVsRef(cwd, ref) {
  const raw = await git(cwd, ['diff', '--relative', '-U5', ref]);
  return parseDiff(raw);
}
async function getDiffStatVsRef(cwd, ref) {
  const out = await git(cwd, ['diff', '--relative', '--stat', '--numstat', ref]);
  return parseNumstat(out);
}

// Diff-stat for the default review base (used so the chooser badge matches what opens).
async function getDefaultDiffStat(cwd) {
  return _diffStatCache(cwd || '', () => _getDefaultDiffStatUncached(cwd));
}
async function _getDefaultDiffStatUncached(cwd) {
  const base = await getDefaultReviewBase(cwd);
  const files = await getDiffStatVsRef(cwd, base.ref);
  return { base, files };
}

// Read a file's full contents for the "new" side of a diff, so the UI can expand context
// lines hidden between hunks. For a commit SHA base the new side is the file at that commit;
// for staged it's the index version; otherwise (working tree / vs-main / uncommitted) it's
// the current working-tree file on disk.
async function getFileAtRef(cwd, relPath, ref) {
  if (relPath.includes('..') || relPath.startsWith('/')) throw new Error('invalid path');
  if (ref && /^[0-9a-f]{7,40}$/.test(ref)) return git(cwd, ['show', `${ref}:${relPath}`]);
  if (ref === '--staged') return git(cwd, ['show', `:${relPath}`]);
  const fs = require('fs'); const path = require('path');
  return fs.promises.readFile(path.join(cwd, relPath), 'utf8');
}

// Parse unified diff format into structured data
function parseDiff(raw) {
  const files = [];
  if (!raw || !raw.trim()) return files;

  const diffBlocks = raw.split(/^diff --git /m).filter(Boolean);

  for (const block of diffBlocks) {
    const lines = block.split('\n');
    // First line: a/path b/path
    const pathMatch = lines[0].match(/a\/(.+?) b\/(.+)/);
    if (!pathMatch) continue;

    const file = {
      oldPath: pathMatch[1],
      path: pathMatch[2],
      language: detectLanguage(pathMatch[2]),
      additions: 0,
      deletions: 0,
      hunks: [],
      isBinary: false,
      isNew: false,
      isDeleted: false,
      isRenamed: pathMatch[1] !== pathMatch[2],
    };

    // Check for binary, new file, deleted file markers
    let headerEnd = 1;
    for (let i = 1; i < lines.length; i++) {
      if (lines[i].startsWith('@@')) { headerEnd = i; break; }
      if (lines[i].startsWith('Binary files')) { file.isBinary = true; headerEnd = lines.length; break; }
      if (lines[i].startsWith('new file')) file.isNew = true;
      if (lines[i].startsWith('deleted file')) file.isDeleted = true;
      headerEnd = i + 1;
    }

    if (file.isBinary) { files.push(file); continue; }

    // Parse hunks
    let currentHunk = null;
    for (let i = headerEnd; i < lines.length; i++) {
      const line = lines[i];
      const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)/);
      if (hunkMatch) {
        currentHunk = {
          oldStart: parseInt(hunkMatch[1]),
          oldLines: parseInt(hunkMatch[2] || '1'),
          newStart: parseInt(hunkMatch[3]),
          newLines: parseInt(hunkMatch[4] || '1'),
          header: hunkMatch[5].trim(),
          lines: [],
        };
        file.hunks.push(currentHunk);
        continue;
      }

      if (!currentHunk) continue;

      if (line.startsWith('+')) {
        currentHunk.lines.push({ type: 'add', content: line.slice(1) });
        file.additions++;
      } else if (line.startsWith('-')) {
        currentHunk.lines.push({ type: 'del', content: line.slice(1) });
        file.deletions++;
      } else if (line.startsWith(' ') || line === '') {
        currentHunk.lines.push({ type: 'ctx', content: line.slice(1) });
      } else if (line.startsWith('\\')) {
        // "No newline at end of file" marker - skip
      }
    }

    files.push(file);
  }

  // Assign line numbers to each line in each hunk
  for (const file of files) {
    for (const hunk of file.hunks) {
      let oldNum = hunk.oldStart;
      let newNum = hunk.newStart;
      for (const line of hunk.lines) {
        if (line.type === 'ctx') {
          line.oldNum = oldNum++;
          line.newNum = newNum++;
        } else if (line.type === 'add') {
          line.oldNum = null;
          line.newNum = newNum++;
        } else if (line.type === 'del') {
          line.oldNum = oldNum++;
          line.newNum = null;
        }
      }
    }
  }

  return files;
}

// Detect language from file extension for syntax highlighting class
function detectLanguage(filePath) {
  const ext = path.extname(filePath).toLowerCase();
  const map = {
    '.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript',
    '.ts': 'typescript', '.tsx': 'typescript',
    '.py': 'python', '.rb': 'ruby', '.go': 'go',
    '.rs': 'rust', '.java': 'java', '.kt': 'kotlin',
    '.c': 'c', '.cpp': 'cpp', '.h': 'c', '.hpp': 'cpp',
    '.css': 'css', '.scss': 'scss', '.less': 'less',
    '.html': 'html', '.htm': 'html', '.xml': 'xml',
    '.json': 'json', '.yaml': 'yaml', '.yml': 'yaml',
    '.md': 'markdown', '.sh': 'bash', '.zsh': 'bash',
    '.sql': 'sql', '.graphql': 'graphql',
    '.toml': 'toml', '.ini': 'ini', '.cfg': 'ini',
    '.dockerfile': 'dockerfile', '.svelte': 'svelte',
    '.vue': 'vue', '.jsx': 'javascript',
  };
  return map[ext] || 'plaintext';
}

// --- Worktree Management ---

// List all git worktrees with metadata
async function listWorktrees(cwd) {
  const out = await git(cwd, ['worktree', 'list', '--porcelain']);
  const worktrees = [];
  let current = null;

  for (const line of out.split('\n')) {
    if (line.startsWith('worktree ')) {
      if (current) worktrees.push(current);
      const wtPath = line.slice(9);
      current = {
        path: wtPath,
        worktreeName: path.basename(wtPath || ''),
        branch: null,
        head: null,
        isMain: false,
        bare: false,
      };
    } else if (line.startsWith('HEAD ') && current) {
      current.head = line.slice(5);
    } else if (line.startsWith('branch ') && current) {
      current.branch = line.slice(7).replace('refs/heads/', '');
    } else if (line === 'bare' && current) {
      current.bare = true;
    }
  }
  if (current) worktrees.push(current);

  // First worktree is always the main one
  if (worktrees.length > 0) worktrees[0].isMain = true;

  return worktrees;
}

async function _branchExists(cwd, branch) {
  try {
    await git(cwd, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]);
    return true;
  } catch (_) {
    return false;
  }
}

function _formatDuration(ms) {
  if (ms >= 1000 && ms % 1000 === 0) return `${ms / 1000}s`;
  if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
  return `${ms}ms`;
}

async function _cleanupPartialWorktree(cwd, worktreePath, branchName, options = {}) {
  const fs = require('fs');
  const cleanup = {
    attempted: true,
    worktreeRemoved: false,
    pathRemoved: false,
    pathAlreadyGone: false,
    pruned: false,
    branchDeleted: false,
    errors: [],
  };

  await new Promise(resolve => setTimeout(resolve, 150));

  try {
    await gitStrict(cwd, ['worktree', 'remove', '--force', worktreePath], 1024 * 1024, {
      timeoutMs: WORKTREE_CLEANUP_TIMEOUT_MS,
      operation: `cleanup partial worktree ${worktreePath}`,
    });
    cleanup.worktreeRemoved = true;
  } catch (e) {
    const notRegistered = /not a working tree|is not a working tree/i.test(String(e.message || ''));
    if (fs.existsSync(worktreePath)) {
      try {
        fs.rmSync(worktreePath, { recursive: true, force: true });
        cleanup.pathRemoved = true;
      } catch (rmErr) {
        cleanup.errors.push(`path remove: ${rmErr.message}`);
      }
    } else {
      cleanup.pathAlreadyGone = true;
    }
    if (!notRegistered) {
      cleanup.errors.push(`worktree remove: ${e.message}`);
    }
  }

  try {
    await gitStrict(cwd, ['worktree', 'prune'], 1024 * 1024, {
      timeoutMs: WORKTREE_CLEANUP_TIMEOUT_MS,
      operation: 'prune partial worktree metadata',
    });
    cleanup.pruned = true;
  } catch (e) {
    cleanup.errors.push(`worktree prune: ${e.message}`);
  }

  if (options.deleteBranch && branchName && await _branchExists(cwd, branchName)) {
    try {
      await gitStrict(cwd, ['branch', '-D', branchName], 1024 * 1024, {
        timeoutMs: WORKTREE_CLEANUP_TIMEOUT_MS,
        operation: `delete partial worktree branch ${branchName}`,
      });
      cleanup.branchDeleted = true;
    } catch (e) {
      cleanup.errors.push(`branch delete: ${e.message}`);
    }
  }

  return cleanup;
}

async function _gitWorktreeAdd(cwd, args, context) {
  const timeoutMs = _positiveInt(context?.timeoutMs, DEFAULT_WORKTREE_CREATE_TIMEOUT_MS);
  const onProgress = typeof context?.onProgress === 'function' ? context.onProgress : null;
  const branchName = context?.branchName || '';
  const worktreePath = context?.worktreePath || '';
  _emitGitProgress(onProgress, {
    stage: 'git_worktree_add',
    status: 'running',
    branch: branchName,
    worktreePath,
    message: `Starting git worktree checkout for "${branchName}".`,
  });
  try {
    await _runGitSpawn(cwd, args, 1024 * 1024 * 5, {
      timeoutMs,
      operation: `create worktree ${branchName || ''}`.trim(),
      timeoutCode: 'WORKTREE_CREATE_TIMEOUT',
      cancelCode: 'WORKTREE_CREATE_CANCELLED',
      signal: context?.signal,
      heartbeatMs: context?.heartbeatMs || 1000,
      onProgress: (event) => _emitGitProgress(onProgress, {
        stage: 'git_worktree_add',
        branch: branchName,
        worktreePath,
        ...event,
      }),
    });
  } catch (e) {
    e.stage = 'git_worktree_add';
    e.branch = branchName;
    e.worktreePath = worktreePath;
    if (e.code === 'WORKTREE_CREATE_CANCELLED' || e.cancelled) {
      e.code = 'WORKTREE_CREATE_CANCELLED';
      e.statusCode = 499;
      e.cleanup = await _cleanupPartialWorktree(cwd, e.worktreePath, e.branch, {
        deleteBranch: !!context?.deleteCreatedBranch,
      });
      e.message = `Worktree creation was cancelled while checking out "${e.branch}".`;
      e.message += e.cleanup?.errors?.length
        ? ' CTM attempted cleanup, but cleanup was incomplete; run Worktrees > Prune if the branch/path remains.'
        : ' CTM cleaned up the partial checkout; retrying is safe.';
    }
    if (e.code === 'WORKTREE_CREATE_TIMEOUT' || e.timedOut) {
      e.code = 'WORKTREE_CREATE_TIMEOUT';
      e.statusCode = 504;
      e.timeoutMs = timeoutMs;
      e.cleanup = await _cleanupPartialWorktree(cwd, e.worktreePath, e.branch, {
        deleteBranch: !!context?.deleteCreatedBranch,
      });
      e.message = `Worktree creation timed out after ${_formatDuration(timeoutMs)} while checking out "${e.branch}".`;
      e.message += e.cleanup?.errors?.length
        ? ' CTM attempted cleanup, but cleanup was incomplete; run Worktrees > Prune if the branch/path remains.'
        : ' CTM cleaned up the partial checkout; retrying is safe.';
    }
    throw e;
  }
}

function _abortError(message) {
  const err = new Error(message || 'Worktree creation was cancelled.');
  err.code = 'WORKTREE_CREATE_CANCELLED';
  err.cancelled = true;
  err.statusCode = 499;
  return err;
}

function _throwIfAborted(signal) {
  if (signal && signal.aborted) throw _abortError();
}

// Create or attach to a worktree. The user shouldn't be blocked when a
// worktree at this name already exists from a prior session — they almost
// always want to reuse it. Behavior:
//   - Existing registered worktree at our target path on the same-named
//     branch → reuse (returns reused: true).
//   - Existing branch with the same name but no checkout → attach a new
//     worktree to it (no -b).
//   - Path exists on disk but unregistered, or path/branch is in use by
//     a different mapping → auto-suffix the name (-2, -3, …) up to -20.
async function createWorktree(cwd, name, baseBranch, options) {
  const fs = require('fs');
  if (baseBranch && typeof baseBranch === 'object') {
    options = baseBranch;
    baseBranch = undefined;
  }
  const base = baseBranch || 'HEAD';
  const namespace = _worktreeNamespace(options);
  const createTimeoutMs = _positiveInt(options?.createTimeoutMs, DEFAULT_WORKTREE_CREATE_TIMEOUT_MS);
  const signal = options?.signal || null;
  const onProgress = typeof options?.onProgress === 'function' ? options.onProgress : null;
  const emitProgress = (event) => _emitGitProgress(onProgress, {
    stage: event?.stage || 'create_worktree',
    status: event?.status || 'running',
    requestedName: name,
    namespace,
    ...(event || {}),
  });
  _throwIfAborted(signal);
  const parentDir = _worktreeParentDir(cwd, namespace);
  fs.mkdirSync(parentDir, { recursive: true });

  emitProgress({ stage: 'scan_existing', message: 'Inspecting existing worktrees and branches.' });
  const worktrees = await listWorktrees(cwd).catch(() => []);
  const _real = (p) => { try { return fs.realpathSync(p); } catch (_) { return p; } };

  // Reuse: existing worktree at our target path on the same-named branch.
  const initialPath = path.join(parentDir, name);
  const initialReal = _real(initialPath);
  const existingAtPath = worktrees.find(w => _real(w.path) === initialReal);
  if (existingAtPath && existingAtPath.branch === name) {
    emitProgress({
      stage: 'reuse_existing',
      status: 'succeeded',
      branch: existingAtPath.branch,
      worktreePath: existingAtPath.path,
      message: `Reusing existing worktree "${name}".`,
    });
    return { path: existingAtPath.path, branch: existingAtPath.branch, reused: true, namespace };
  }

  // Attach to existing branch (no clash on path). Common after a worktree
  // was removed but its branch was kept.
  if (!existingAtPath && !fs.existsSync(initialPath) && await _branchExists(cwd, name)
      && !worktrees.some(w => w.branch === name)) {
    _throwIfAborted(signal);
    await _gitWorktreeAdd(cwd, ['worktree', 'add', initialPath, name], {
      timeoutMs: createTimeoutMs,
      branchName: name,
      worktreePath: initialPath,
      deleteCreatedBranch: false,
      signal,
      onProgress: emitProgress,
    });
    emitProgress({
      stage: 'attach_existing_branch',
      status: 'succeeded',
      branch: name,
      worktreePath: initialPath,
      message: `Attached worktree to existing branch "${name}".`,
    });
    return { path: initialPath, branch: name, attached: true, namespace };
  }

  // Pick a free (path, branch) pair. Suffix until both slots are clear.
  for (let attempt = 1; attempt <= 20; attempt++) {
    _throwIfAborted(signal);
    const candidateName = attempt === 1 ? name : `${name}-${attempt}`;
    const candidatePath = path.join(parentDir, candidateName);
    const candidateReal = _real(candidatePath);
    const pathInUse = worktrees.some(w => _real(w.path) === candidateReal);
    const branchInUse = await _branchExists(cwd, candidateName);
    const pathOnDisk = fs.existsSync(candidatePath);
    if (pathInUse || branchInUse || pathOnDisk) continue;
    await _gitWorktreeAdd(cwd, ['worktree', 'add', '-b', candidateName, candidatePath, base], {
      timeoutMs: createTimeoutMs,
      branchName: candidateName,
      worktreePath: candidatePath,
      deleteCreatedBranch: true,
      signal,
      onProgress: emitProgress,
    });
    emitProgress({
      stage: 'create_branch',
      status: 'succeeded',
      branch: candidateName,
      worktreePath: candidatePath,
      message: `Created worktree "${candidateName}".`,
    });
    return {
      path: candidatePath,
      branch: candidateName,
      namespace,
      ...(candidateName !== name ? { suffixed: true, requestedName: name } : {}),
    };
  }
  throw new Error(`Could not find a free worktree slot for "${name}" (tried up to ${name}-20)`);
}

// Check for merge conflicts before merging (requires git 2.38+)
async function mergeWorktreePreCheck(cwd, branchName, targetBranch) {
  targetBranch = targetBranch || 'main';
  // Use execFile directly so we can inspect exit code. Our shared git()
  // helper resolves on non-zero exit when stdout is non-empty, which hides
  // merge-tree --write-tree's exit=1 signal for conflicts.
  return new Promise((resolve, reject) => {
    execFile('git', ['merge-tree', '--write-tree', targetBranch, branchName],
      { cwd, timeout: 15000, maxBuffer: 1024 * 1024 * 5 },
      (err, stdout, stderr) => {
        if (!err) { resolve({ conflicts: false }); return; }
        const combined = String(stdout || '') + String(stderr || '');
        // Exit 1 == conflicts (git 2.38+). --write-tree on older git exits
        // with a different error — fall back to "no conflicts detected".
        if (err.code === 1) { resolve({ conflicts: true }); return; }
        if (combined.includes('unknown option') || combined.includes('not a git command')) {
          resolve({ conflicts: false });
          return;
        }
        reject(err);
      });
  });
}

// Merge a worktree branch into target (default: main)
async function mergeWorktree(cwd, branchName, targetBranch, strategy) {
  targetBranch = targetBranch || 'main';
  const currentBranch = await getBranch(cwd);

  // Must be on target branch to merge
  if (currentBranch !== targetBranch) {
    throw new Error(`Must be on ${targetBranch} branch to merge. Currently on: ${currentBranch}`);
  }

  const args = ['merge', branchName];
  if (strategy === 'squash') args.push('--squash');
  if (strategy === 'no-ff') args.push('--no-ff');

  await git(cwd, args);

  // Squash merge stages changes but doesn't commit — complete the commit
  if (strategy === 'squash') {
    await git(cwd, ['commit', '-m', `Squash merge branch '${branchName}'`]);
  }

  return { merged: true, branch: branchName, into: targetBranch };
}

function _oneLineCommitMessage(message, fallback) {
  const value = String(message || fallback || 'Finish local work')
    .replace(/[\r\n]+/g, ' ')
    .replace(/\s+/g, ' ')
    .trim()
    .slice(0, 180);
  return value || 'Finish local work';
}

async function commitWorktreeChanges(worktreePath, message) {
  const status = String(await gitStrict(worktreePath, ['status', '--porcelain=v1', '--untracked-files=all']) || '');
  if (!status.trim()) {
    return { committed: false, dirty: false };
  }
  await gitStrict(worktreePath, [
    'add', '-A', '--', '.',
    ':(exclude).claude/worktrees',
    ':(exclude).claude/worktrees/**',
    ':(exclude).walle/worktrees',
    ':(exclude).walle/worktrees/**',
  ]);
  const stagedExit = await gitExitCode(worktreePath, ['diff', '--cached', '--quiet']);
  if (stagedExit === 0) {
    return { committed: false, dirty: true, ignoredOnly: true };
  }
  if (stagedExit !== 1) {
    throw new Error('Could not inspect staged worktree changes before committing.');
  }
  const subject = _oneLineCommitMessage(message, 'Finish local work');
  await gitStrict(worktreePath, ['commit', '-m', subject]);
  const commit = String(await gitStrict(worktreePath, ['rev-parse', '--short', 'HEAD']) || '').trim();
  return { committed: true, dirty: true, commit, message: subject };
}

async function _findWorktreeForFinish(cwd, branchName, opts) {
  const worktreePath = String(opts?.worktreePath || '').trim();
  const wantedPath = worktreePath ? _realpath(worktreePath) : '';
  const worktrees = await listWorktrees(cwd);
  let wt = null;
  if (wantedPath) {
    wt = worktrees.find(item => _realpath(item.path) === wantedPath);
  }
  if (!wt && branchName) {
    wt = worktrees.find(item => item.branch === branchName || item.worktreeName === branchName);
  }
  if (!wt && branchName === 'main') {
    wt = worktrees.find(item => item.isMain);
  }
  if (!wt) throw new Error(`Worktree not found for branch: ${branchName || worktreePath}`);
  return wt;
}

async function finishWorktree(cwd, branchName, opts) {
  opts = opts || {};
  const targetBranch = opts.targetBranch || 'main';
  const strategy = opts.strategy;
  const commitDirty = opts.commitDirty !== false;
  const wt = await _findWorktreeForFinish(cwd, branchName, opts);
  const branch = wt.branch || branchName || await getBranch(wt.path);
  if (!branch || branch === 'HEAD') throw new Error('Cannot finish a detached worktree from the phone. Recover a branch first.');

  let commitResult = { committed: false, dirty: false };
  const dirtyStatus = String(await gitStrict(wt.path, ['status', '--porcelain=v1', '--untracked-files=all']) || '');
  if (dirtyStatus.trim()) {
    if (!commitDirty) {
      throw new Error('Worktree has uncommitted changes. Commit or stash before merging.');
    }
    commitResult = await commitWorktreeChanges(wt.path, opts.commitMessage || `Finish ${branch} work`);
  }

  if (branch === targetBranch) {
    return {
      merged: false,
      alreadyMain: true,
      branch,
      into: targetBranch,
      worktreePath: wt.path,
      committed: !!commitResult.committed,
      commit: commitResult.commit || '',
      commitMessage: commitResult.message || '',
    };
  }

  const pre = await mergeWorktreePreCheck(cwd, branch, targetBranch);
  if (pre.conflicts) {
    const err = new Error('Merge would have conflicts. Resolve manually.');
    err.code = 'MERGE_CONFLICTS';
    throw err;
  }
  const merged = await mergeWorktree(cwd, branch, targetBranch, strategy);
  return {
    ...merged,
    worktreePath: wt.path,
    committed: !!commitResult.committed,
    commit: commitResult.commit || '',
    commitMessage: commitResult.message || '',
  };
}

// Remove a worktree and optionally delete its branch
async function removeWorktree(cwd, worktreePath, deleteBranch) {
  // Get branch name before removing. Match tolerates /var ↔ /private/var
  // symlink differences on macOS by using realpath AND basename fallback.
  let branchName = null;
  if (deleteBranch) {
    try {
      const worktrees = await listWorktrees(cwd);
      const want = _realpath(worktreePath);
      const baseName = path.basename(worktreePath);
      const wt = worktrees.find(w =>
        w.path === worktreePath
        || _realpath(w.path) === want
        || path.basename(w.path) === baseName
      );
      if (wt) branchName = wt.branch;
    } catch (e) { console.warn('[removeWorktree] listWorktrees failed:', e.message); }
  }

  await git(cwd, ['worktree', 'remove', worktreePath, '--force']);

  let branchDeleted = false;
  if (branchName && deleteBranch) {
    try {
      await git(cwd, ['branch', '-D', branchName]);
      branchDeleted = true;
    } catch (e) {
      console.warn(`[removeWorktree] branch -D ${branchName} failed:`, e.message);
    }
  }

  return { removed: true, path: worktreePath, branchDeleted };
}

// Delete a local branch. `-d` refuses to drop unmerged work; `force` uses `-D`.
// Caller is responsible for ensuring the branch has no worktree and is not in use
// by a live session. Throws on git failure (e.g. unmerged without force).
async function deleteBranch(cwd, branchName, force) {
  const flag = force ? '-D' : '-d';
  await gitStrict(cwd, ['branch', flag, branchName]);
  return { deleted: true, branch: branchName, forced: !!force };
}

// Best-effort realpath resolution — falls back to the input if it can't be
// resolved (e.g. already deleted, permission issue).
function _realpath(p) {
  try { return require('fs').realpathSync(p); } catch { return p; }
}

// --- Rich worktree status (Pillars 1, 4b, 5, 6) ---
//
// listRichWorktrees(cwd, mainBranch) returns each worktree augmented with:
//   - state: 'primary'|'clean'|'ahead'|'behind'|'diverged'|'dirty'|'detached'|'ghost'
//   - isCanonical: path lives under an agent-owned <repo>/.claude|.walle/worktrees/<name>
//   - isGhost: path contains /~/ corruption OR path doesn't exist on disk
//   - ahead/behind vs mainBranch
//   - dirtyFiles: count of tracked dirty + untracked files
//   - trackedDirtyFiles/untrackedFiles: split counts so untracked-only
//     generated artifacts do not block safe sync from main
//   - unmergedCommits: rev-list count main..HEAD (0 ⇒ safe to delete)
//   - lastActivity: ISO timestamp of HEAD commit
//   - summary: 1-line human-readable status
//
// All git calls are best-effort — failures degrade gracefully to nulls.
const fs = require('fs');

async function _gitSafe(cwd, args, timeoutMs = 5000) {
  return new Promise((resolve) => {
    execFile('git', args, { cwd, timeout: timeoutMs, maxBuffer: 1024 * 1024 }, (err, stdout) => {
      if (err) return resolve(null);
      resolve(String(stdout || '').trim());
    });
  });
}

async function _gitSafeRaw(cwd, args, timeoutMs = 5000) {
  return new Promise((resolve) => {
    execFile('git', args, { cwd, timeout: timeoutMs, maxBuffer: 1024 * 1024 }, (err, stdout) => {
      if (err) return resolve(null);
      resolve(String(stdout || ''));
    });
  });
}

function _parsePorcelainStatusZ(out) {
  const entries = String(out || '').split('\0').filter(Boolean);
  const result = {
    dirtyFiles: 0,
    trackedDirtyFiles: 0,
    untrackedFiles: 0,
    ignoredFiles: 0,
    unmergedFiles: 0,
    stagedFiles: 0,
    unstagedFiles: 0,
    untrackedPaths: [],
  };

  for (let i = 0; i < entries.length; i++) {
    const entry = entries[i];
    if (entry.length < 3) continue;
    const xy = entry.slice(0, 2);
    const filePath = entry.slice(3);
    if (xy === '??') {
      result.untrackedFiles += 1;
      result.untrackedPaths.push(filePath);
      continue;
    }
    if (xy === '!!') {
      result.ignoredFiles += 1;
      continue;
    }

    result.trackedDirtyFiles += 1;
    if (xy[0] && xy[0] !== ' ') result.stagedFiles += 1;
    if (xy[1] && xy[1] !== ' ') result.unstagedFiles += 1;
    if (xy.includes('U') || ['AA', 'DD'].includes(xy)) result.unmergedFiles += 1;
    // In porcelain v1 -z, rename/copy records include the source path as a
    // second NUL-terminated field. Skip it so one rename counts once.
    if (xy[0] === 'R' || xy[0] === 'C') i += 1;
  }

  result.dirtyFiles = result.trackedDirtyFiles + result.untrackedFiles;
  result.hasOnlyUntrackedFiles = result.untrackedFiles > 0 && result.trackedDirtyFiles === 0;
  return result;
}

function _trackedDirtyCount(wt) {
  if (!wt) return 0;
  if (wt.trackedDirtyFiles != null) return Number(wt.trackedDirtyFiles || 0);
  const dirty = Number(wt.dirtyFiles || 0);
  const untracked = Number(wt.untrackedFiles || 0);
  return Math.max(0, dirty - untracked);
}

function _hasOnlyUntrackedFiles(wt) {
  if (!wt) return false;
  const dirty = Number(wt.dirtyFiles || 0);
  return dirty > 0 && _trackedDirtyCount(wt) === 0;
}

function _splitNulPaths(out) {
  return String(out || '').split('\0').filter(Boolean);
}

function _pathsOverlap(a, b) {
  const left = String(a || '').replace(/\/+$/, '');
  const right = String(b || '').replace(/\/+$/, '');
  if (!left || !right) return false;
  return left === right || left.startsWith(right + '/') || right.startsWith(left + '/');
}

async function _untrackedSyncCollisions(worktreePath, targetBranch) {
  const status = _parsePorcelainStatusZ(await _gitSafeRaw(worktreePath, ['status', '--porcelain=v1', '-uall', '-z']) || '');
  if (status.trackedDirtyFiles > 0 || status.untrackedPaths.length === 0) {
    return { status, collisions: [] };
  }
  const changedOut = await _gitSafeRaw(worktreePath, ['diff', '--name-only', '-z', 'HEAD', targetBranch], 8000);
  const changedPaths = _splitNulPaths(changedOut);
  if (changedPaths.length === 0) return { status, collisions: [] };
  const collisions = [];
  for (const untrackedPath of status.untrackedPaths) {
    const hit = changedPaths.find(changedPath => _pathsOverlap(untrackedPath, changedPath));
    if (hit) collisions.push({ untrackedPath, incomingPath: hit });
  }
  return { status, collisions };
}

function _checkpointRefSlug(branchName) {
  let slug = String(branchName || 'branch')
    .replace(/[^A-Za-z0-9._-]+/g, '-')
    .replace(/^-+/, '')
    .replace(/\.+$/, '')
    .replace(/\.\.+/g, '.')
    .slice(0, 80);
  if (!slug || slug === '.' || slug.toLowerCase().endsWith('.lock')) slug = 'branch';
  return slug;
}

function _checkpointRefName(branchName, head, now = Date.now(), suffix = '') {
  const stamp = new Date(now).toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
  const shortHead = String(head || '').slice(0, 10) || 'unknown';
  return `ctm/pre-sync/${_checkpointRefSlug(branchName)}/${stamp}-${shortHead}${suffix}`;
}

async function _createWorktreeCheckpoint(repoRoot, branchName, head) {
  if (!head) return '';
  for (let i = 0; i < 5; i++) {
    const refName = _checkpointRefName(branchName, head, Date.now(), i ? `-${i + 1}` : '');
    const out = await _gitSafe(repoRoot, ['branch', refName, head], 8000);
    if (out !== null) return refName;
  }
  return '';
}

function _parseGitHubRemoteUrl(remoteUrl) {
  const raw = String(remoteUrl || '').trim();
  if (!raw) return null;

  let host = 'github.com';
  let owner = '';
  let repo = '';
  let match = raw.match(/^git@([^:]+):([^/]+)\/(.+)$/);
  if (match) {
    host = match[1];
    owner = match[2];
    repo = match[3];
  } else {
    match = raw.match(/^ssh:\/\/(?:[^@/]+@)?([^/]+)\/([^/]+)\/(.+)$/);
    if (match) {
      host = match[1];
      owner = match[2];
      repo = match[3];
    } else {
      match = raw.match(/^https?:\/\/([^/]+)\/([^/]+)\/(.+)$/);
      if (!match) return null;
      host = match[1];
      owner = match[2];
      repo = match[3];
    }
  }

  repo = repo.replace(/(?:\.git)?\/?$/, '');
  if (!host || !owner || !repo || repo.includes('/')) return null;
  const nameWithOwner = `${owner}/${repo}`;
  return {
    host,
    owner,
    repo,
    nameWithOwner,
    ghRepo: host === 'github.com' ? nameWithOwner : `${host}/${nameWithOwner}`,
  };
}

async function _githubRemoteSpec(cwd, remoteName) {
  if (!remoteName) return null;
  const urls = [
    await _gitSafe(cwd, ['remote', 'get-url', remoteName]),
    await _gitSafe(cwd, ['remote', 'get-url', '--push', remoteName]),
  ].filter(Boolean);
  for (const url of urls) {
    const spec = _parseGitHubRemoteUrl(url);
    if (spec) return { ...spec, remote: remoteName, url };
  }
  return null;
}

async function _selectPrBaseRemote(cwd, base, remoteNames) {
  const remotes = Array.isArray(remoteNames) ? remoteNames : [];
  if (remotes.includes('upstream') && await _githubRemoteSpec(cwd, 'upstream')) {
    return 'upstream';
  }

  const configured = await _gitSafe(cwd, ['config', '--get', `branch.${base}.remote`]);
  if (configured && configured !== '.' && remotes.includes(configured)
      && await _githubRemoteSpec(cwd, configured)) {
    return configured;
  }

  if (remotes.includes('origin') && await _githubRemoteSpec(cwd, 'origin')) {
    return 'origin';
  }

  for (const remote of remotes) {
    if (await _githubRemoteSpec(cwd, remote)) return remote;
  }
  return null;
}

async function _buildGhPrCreateArgs(cwd, branchName, opts) {
  opts = opts || {};
  const base = opts.base || 'main';
  const title = opts.title || branchName;
  const body = opts.body || `Branch \`${branchName}\` opened.`;
  const remoteOut = await _gitSafe(cwd, ['remote']);
  const remoteNames = remoteOut ? remoteOut.split('\n').map(s => s.trim()).filter(Boolean) : [];
  const pushRemote = remoteNames.includes('origin') ? 'origin' : remoteNames[0];
  const baseRemote = await _selectPrBaseRemote(cwd, base, remoteNames);
  const baseSpec = await _githubRemoteSpec(cwd, baseRemote);
  const pushSpec = await _githubRemoteSpec(cwd, pushRemote);

  let head = branchName;
  if (baseSpec && pushSpec
      && (baseSpec.host !== pushSpec.host
        || baseSpec.owner !== pushSpec.owner
        || baseSpec.repo !== pushSpec.repo)) {
    head = `${pushSpec.owner}:${branchName}`;
  }

  const args = ['pr', 'create'];
  if (baseSpec) args.push('--repo', baseSpec.ghRepo);
  args.push('--base', base, '--head', head, '--title', title, '--body', body);
  return { args, baseRemote, pushRemote, baseSpec, pushSpec, head };
}

function _isGhostPath(p) {
  if (!p) return true;
  if (p.includes('/~/')) return true;          // literal-tilde corruption
  try { return !fs.existsSync(p); } catch { return true; }
}

function _isCanonicalPath(repoRoot, p, isMain) {
  if (isMain) return true;
  const value = String(p || '');
  const expectedClaude = path.join(repoRoot, '.claude', 'worktrees') + path.sep;
  const expectedWalle = path.join(repoRoot, '.walle', 'worktrees') + path.sep;
  return value.startsWith(expectedClaude) || value.startsWith(expectedWalle);
}

function _classifyState(wt) {
  if (wt.isGhost) return 'ghost';
  if (wt.isMain) return 'primary';
  if (!wt.branch || wt.branch === 'HEAD') return 'detached';
  if (_trackedDirtyCount(wt) > 0) return 'dirty';
  const ahead = wt.ahead || 0;
  const behind = wt.behind || 0;
  if (ahead > 0 && behind > 0) return 'diverged';
  if (ahead > 0) return 'ahead';
  if (behind > 0) return 'behind';
  return 'clean';
}

function _formatRelativeTime(iso) {
  if (!iso) return '';
  const t = Date.parse(iso);
  if (!t) return '';
  const sec = Math.max(0, Math.floor((Date.now() - t) / 1000));
  if (sec < 60) return `${sec}s ago`;
  if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
  if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
  const d = Math.floor(sec / 86400);
  if (d < 30) return `${d}d ago`;
  if (d < 365) return `${Math.floor(d / 30)}mo ago`;
  return `${Math.floor(d / 365)}y ago`;
}

function _buildSummary(wt) {
  if (wt.isGhost) return 'Ghost worktree — corrupt or missing path. Click Prune to clean up.';
  if (wt.isMain) {
    const remote = wt.mainRemote || {};
    if (remote.state === 'behind') return `Primary worktree — main is ${remote.behind} behind ${remote.remote}.`;
    if (remote.state === 'ahead') return `Primary worktree — main has ${remote.ahead} unpushed commit(s).`;
    if (remote.state === 'diverged') return `Primary worktree — main has diverged from ${remote.remote}.`;
    return 'Primary worktree';
  }
  if (wt.state === 'detached') return 'Detached HEAD — commits here are at risk. Click Recover branch.';
  const trackedDirty = _trackedDirtyCount(wt);
  const untrackedFiles = Number(wt.untrackedFiles || 0);
  const parts = [];
  if (wt.ahead > 0) parts.push(`${wt.ahead} ahead`);
  if (wt.behind > 0) parts.push(`${wt.behind} behind`);
  if (trackedDirty > 0) parts.push(`${trackedDirty} dirty`);
  if (untrackedFiles > 0) parts.push(`${untrackedFiles} untracked`);
  if (parts.length === 0) return 'Clean — synced with main';
  let suffix = '';
  if (wt.state === 'ahead') suffix = ' — ready to merge';
  else if (wt.state === 'behind') suffix = ' — sync from main';
  else if (wt.state === 'diverged') suffix = ' — needs sync first';
  else if (wt.state === 'dirty') suffix = ' — commit or stash';
  else if (wt.state === 'clean' && untrackedFiles > 0) suffix = ' — untracked files kept local';
  return parts.join(', ') + suffix;
}

function _classifyMainRemote(mainRemote) {
  if (!mainRemote || !mainRemote.remote) return 'unknown';
  const ahead = mainRemote.ahead || 0;
  const behind = mainRemote.behind || 0;
  if (ahead > 0 && behind > 0) return 'diverged';
  if (ahead > 0) return 'ahead';
  if (behind > 0) return 'behind';
  return 'synced';
}

async function _mainRemoteStatus(repoRoot, mainBranch) {
  const remoteCandidates = [];
  const upstream = await _gitSafe(repoRoot, ['rev-parse', '--abbrev-ref', `${mainBranch}@{upstream}`]);
  if (upstream && !upstream.startsWith('fatal')) remoteCandidates.push(upstream);
  remoteCandidates.push(`origin/${mainBranch}`);

  let remote = null;
  const seen = new Set();
  for (const candidate of remoteCandidates) {
    if (!candidate || seen.has(candidate)) continue;
    seen.add(candidate);
    const exists = await _gitSafe(repoRoot, ['rev-parse', '--verify', '--quiet', candidate]);
    if (exists) { remote = candidate; break; }
  }

  const result = {
    branch: mainBranch,
    remote,
    ahead: 0,
    behind: 0,
    state: 'unknown',
    summary: 'No upstream configured for main',
  };
  if (!remote) return result;

  const revlist = await _gitSafe(repoRoot, ['rev-list', '--left-right', '--count', `${remote}...${mainBranch}`]);
  if (revlist) {
    const parts = revlist.split(/\s+/).filter(Boolean);
    if (parts.length >= 2) {
      // left = commits on remote not in local main; right = local main not on remote.
      result.behind = parseInt(parts[0], 10) || 0;
      result.ahead = parseInt(parts[1], 10) || 0;
    }
  }
  result.state = _classifyMainRemote(result);
  if (result.state === 'synced') result.summary = `main is synced with ${remote}`;
  else if (result.state === 'ahead') result.summary = `main has ${result.ahead} unpushed commit(s)`;
  else if (result.state === 'behind') result.summary = `main is ${result.behind} behind ${remote}`;
  else if (result.state === 'diverged') result.summary = `main and ${remote} both have unique commits`;
  return result;
}

function _recommendedAction(wt) {
  if (wt.isGhost) return { kind: 'prune', label: 'Prune', tone: 'danger', reason: 'Git metadata points at a missing or corrupt path.' };
  if (wt.isMain) {
    const remote = wt.mainRemote || {};
    if (remote.state === 'behind') return { kind: 'update_main', label: 'Update main', tone: 'warning', reason: remote.summary };
    if (remote.state === 'ahead') return { kind: 'push_main', label: 'Push main', tone: 'info', reason: remote.summary };
    if (remote.state === 'diverged') return { kind: 'reconcile_main', label: 'Review main', tone: 'danger', reason: remote.summary };
    return { kind: 'primary', label: 'Primary', tone: 'neutral', reason: 'Main checkout.' };
  }
  if (!wt.branch || wt.branch === 'HEAD') return { kind: 'recover_branch', label: 'Recover branch', tone: 'danger', reason: 'Detached HEAD commits can become hard to find.' };
  const trackedDirty = _trackedDirtyCount(wt);
  if (trackedDirty > 0) return { kind: 'review_dirty', label: 'Open session', tone: 'warning', reason: `${trackedDirty} tracked dirty file(s).` };
  if ((wt.ahead || 0) > 0 && (wt.behind || 0) > 0) return { kind: 'sync_branch', label: 'Sync first', tone: 'warning', reason: 'Branch has commits and is behind main.' };
  if ((wt.behind || 0) > 0) return { kind: 'sync_branch', label: 'Sync', tone: 'warning', reason: 'Branch is behind main.' };
  if ((wt.ahead || 0) > 0) return { kind: 'finish_work', label: 'Finish', tone: 'success', reason: 'Branch has committed work not on main.' };
  if (_hasOnlyUntrackedFiles(wt)) return { kind: 'review_dirty', label: 'Open session', tone: 'warning', reason: `${wt.untrackedFiles || wt.dirtyFiles} untracked file(s).` };
  return { kind: 'cleanup', label: 'Clean up', tone: 'neutral', reason: 'No unmerged commits or dirty files.' };
}

const STATE_SORT_RANK = {
  ghost: 0,
  detached: 1,
  diverged: 2,
  ahead: 3,
  behind: 4,
  dirty: 5,
  clean: 6,
  primary: 7,
};

// Detached-HEAD auto-recovery (Pillar 4b): create a branch at HEAD so commits
// don't orphan. Idempotent — skips when this worktree is already checked out
// on any branch (regardless of whether other branches happen to share HEAD).
async function recoverDetachedHead(worktreePath) {
  const head = await _gitSafe(worktreePath, ['rev-parse', 'HEAD']);
  if (!head) return null;
  // If the worktree's HEAD is already a branch (not detached), nothing to do.
  const current = await _gitSafe(worktreePath, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
  if (current) return null;
  const dirName = path.basename(worktreePath).replace(/[^a-zA-Z0-9_-]/g, '-');
  const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
  const branchName = `recovered/${dirName}-${date}`;
  const out = await _gitSafe(worktreePath, ['checkout', '-B', branchName]);
  if (out === null) return null;
  return branchName;
}

function _sameWorktreePath(a, b) {
  if (!a || !b) return false;
  return _realpath(a) === _realpath(b);
}

async function recoverWorktree(cwd, selector) {
  selector = selector || {};
  const requestedName = selector.name || '';
  const requestedPath = selector.path || selector.worktreePath || '';
  const worktrees = await listWorktrees(cwd);
  const wt = worktrees.find(w => requestedPath && _sameWorktreePath(w.path, requestedPath))
    || worktrees.find(w => requestedName && w.worktreeName === requestedName)
    || worktrees.find(w => requestedName && w.branch === requestedName);

  if (!wt) {
    const target = requestedPath || requestedName || 'requested worktree';
    throw new Error(`Worktree not found: ${target}`);
  }
  if (wt.isMain) {
    return {
      ok: true,
      branch: wt.branch || null,
      worktreeName: wt.worktreeName,
      path: wt.path,
      message: 'No recovery needed for the primary checkout.',
    };
  }
  if (wt.branch && wt.branch !== 'HEAD') {
    return {
      ok: true,
      branch: wt.branch,
      worktreeName: wt.worktreeName,
      path: wt.path,
      message: `No recovery needed (already on branch ${wt.branch}).`,
    };
  }

  const branch = await recoverDetachedHead(wt.path);
  if (!branch) throw new Error(`Recovery failed for detached worktree: ${wt.path}`);
  return {
    ok: true,
    branch,
    worktreeName: wt.worktreeName,
    path: wt.path,
    message: `Recovered to branch ${branch}`,
  };
}

// Sync main into a worktree (Pillar 3). Returns {merged, conflicts, message}.
async function syncWorktree(cwd, branchName, strategy, opts) {
  strategy = strategy || 'merge';
  opts = opts || {};
  const repoRoot = (await _gitSafe(cwd, ['rev-parse', '--show-toplevel'])) || cwd;
  const worktrees = await listWorktrees(repoRoot);
  const wt = worktrees.find(w => w.branch === branchName);
  if (!wt) throw new Error(`Worktree not found for branch: ${branchName}`);
  const beforeHead = wt.head || await _gitSafe(wt.path, ['rev-parse', 'HEAD']);
  if (opts.expectedHead && beforeHead && opts.expectedHead !== beforeHead) {
    return {
      merged: false,
      stale: true,
      beforeHead,
      expectedHead: opts.expectedHead,
      message: 'Worktree HEAD changed before sync could start.',
    };
  }
  // Make sure main is up to date locally — try a fast-forward fetch but
  // don't fail if there's no remote.
  await _gitSafe(repoRoot, ['fetch', 'origin', 'main', '--quiet'], 8000);

  // Pre-check for conflicts using merge-tree (git 2.38+).
  const preCheck = await mergeWorktreePreCheck(repoRoot, 'main', branchName).catch(() => ({ conflicts: false }));
  if (preCheck.conflicts) {
    return { merged: false, conflicts: true, message: 'Merge would conflict — open the worktree in a terminal to resolve.' };
  }

  const syncSafety = await _untrackedSyncCollisions(wt.path, 'main');
  if (syncSafety.status.trackedDirtyFiles > 0) {
    return {
      merged: false,
      blocked: true,
      code: 'TRACKED_DIRTY',
      beforeHead,
      message: 'Commit or stash tracked dirty files before syncing from main.',
    };
  }
  if (syncSafety.collisions.length > 0) {
    return {
      merged: false,
      blocked: true,
      code: 'UNTRACKED_COLLISION',
      beforeHead,
      untrackedCollisions: syncSafety.collisions,
      message: `Sync would overwrite untracked file(s): ${syncSafety.collisions.map(c => c.untrackedPath).slice(0, 3).join(', ')}`,
    };
  }

  let checkpointRef = '';
  if (opts.createCheckpoint) {
    checkpointRef = await _createWorktreeCheckpoint(repoRoot, branchName, beforeHead);
    if (!checkpointRef) {
      return {
        merged: false,
        conflicts: false,
        checkpointFailed: true,
        beforeHead,
        message: 'Could not create a pre-sync checkpoint; sync was not started.',
      };
    }
  }

  const args = strategy === 'rebase' ? ['rebase', 'main'] : ['merge', 'main', '--no-edit'];
  const out = await _gitSafe(wt.path, args, 30000);
  if (out === null) {
    return {
      merged: false,
      conflicts: false,
      beforeHead,
      checkpointRef,
      message: 'Merge failed. Check the worktree for unresolved state.',
    };
  }
  const afterHead = await _gitSafe(wt.path, ['rev-parse', 'HEAD']);
  return { merged: true, strategy, beforeHead, afterHead, checkpointRef, message: out || 'Already up to date.' };
}

function _syncAllEligibility(wt) {
  if (!wt || wt.isMain) return { eligible: false, reason: 'primary checkout' };
  if (wt.isGhost || wt.state === 'ghost') return { eligible: false, reason: 'ghost worktree' };
  if (!wt.branch || wt.branch === 'HEAD' || wt.state === 'detached') return { eligible: false, reason: 'detached HEAD' };
  const trackedDirty = _trackedDirtyCount(wt);
  if (trackedDirty > 0) return { eligible: false, reason: `${trackedDirty} tracked dirty file(s)` };
  if ((wt.behind || 0) <= 0) return { eligible: false, reason: 'not behind main' };
  return { eligible: true, reason: '' };
}

async function syncAllWorktrees(cwd, strategy, opts) {
  strategy = strategy || 'merge';
  opts = opts || {};
  if (!['merge', 'rebase'].includes(strategy)) throw new Error('Invalid sync strategy. Use: merge or rebase');

  const worktrees = await listRichWorktrees(cwd);
  const results = [];
  const skippedBranches = [];
  const excludeBranches = new Set(opts.excludeBranches || []);
  const excludePaths = new Set((opts.excludePaths || []).map(p => {
    try { return fs.realpathSync(p); } catch { return p; }
  }));
  const excludePathReasons = new Map(Object.entries(opts.excludePathReasons || {}).map(([p, reason]) => {
    let key = p;
    try { key = fs.realpathSync(p); } catch {}
    return [key, reason];
  }));
  const activeSyncTargets = new Map((opts.activeSyncTargets || []).map(target => {
    let key = target.path || '';
    try { key = fs.realpathSync(key); } catch {}
    return [key, target];
  }));

  for (const wt of worktrees) {
    if (wt.isMain) continue;
    const branch = wt.branch || wt.worktreeName || wt.path;
    const wtPath = wt.path ? (() => { try { return fs.realpathSync(wt.path); } catch { return wt.path; } })() : '';
    if ((wt.branch && excludeBranches.has(wt.branch)) || (wtPath && excludePaths.has(wtPath))) {
      if ((wt.behind || 0) > 0 || ['dirty', 'detached', 'ghost'].includes(wt.state)) {
        skippedBranches.push({
          branch,
          worktreeName: wt.worktreeName || '',
          state: wt.state,
          reason: excludePathReasons.get(wtPath) || 'active session',
        });
      }
      continue;
    }
    const eligibility = _syncAllEligibility(wt);
    if (!eligibility.eligible) {
      if ((wt.behind || 0) > 0 || ['dirty', 'detached', 'ghost'].includes(wt.state)) {
        skippedBranches.push({
          branch,
          worktreeName: wt.worktreeName || '',
          state: wt.state,
          reason: eligibility.reason,
        });
      }
      continue;
    }

    const row = {
      branch: wt.branch,
      worktreeName: wt.worktreeName || '',
      state: wt.state,
      behind: wt.behind || 0,
      ahead: wt.ahead || 0,
    };
    const activeTarget = wtPath ? activeSyncTargets.get(wtPath) : null;
    const syncOpts = activeTarget ? {
      createCheckpoint: true,
      expectedHead: activeTarget.expectedHead || wt.head || '',
    } : undefined;
    const syncStrategy = activeTarget ? 'merge' : strategy;
    try {
      const result = await syncWorktree(cwd, wt.branch, syncStrategy, syncOpts);
      results.push({
        ...row,
        ok: !!result.merged && !result.conflicts,
        merged: !!result.merged,
        conflicts: !!result.conflicts,
        stale: !!result.stale,
        message: result.message || '',
        strategy: result.strategy || syncStrategy,
        checkpointRef: result.checkpointRef || '',
        beforeHead: result.beforeHead || '',
        afterHead: result.afterHead || '',
        activeSession: activeTarget ? {
          id: activeTarget.sessionId || '',
          label: activeTarget.sessionLabel || activeTarget.label || '',
          status: activeTarget.sessionStatus || activeTarget.status || 'idle',
        } : undefined,
      });
    } catch (e) {
      results.push({
        ...row,
        ok: false,
        merged: false,
        conflicts: false,
        error: e.message,
      });
    }
  }

  const synced = results.filter(r => r.ok).length;
  const failed = results.filter(r => !r.ok).length;
  return {
    ok: failed === 0,
    strategy,
    total: results.length,
    synced,
    failed,
    skipped: skippedBranches.length,
    results,
    skippedBranches,
  };
}

// Prune ghost worktrees (Pillar 5). Returns {prunedPaths}.
async function pruneGhosts(cwd) {
  const worktrees = await listWorktrees(cwd);
  const pruned = [];
  for (const wt of worktrees) {
    if (!_isGhostPath(wt.path) || wt.isMain) continue;
    // Try `worktree remove --force` first — it handles the registered-but-missing
    // case and clears the .git/worktrees/<name> bookkeeping. If that fails, fall
    // back to `worktree prune` which clears stale entries by path-existence check.
    let ok = await _gitSafe(cwd, ['worktree', 'remove', '--force', wt.path]);
    if (ok === null) {
      // The registered path may have literal `~` that git can't resolve — fall
      // back to deleting the bookkeeping directory directly under .git/worktrees/.
      const wtName = path.basename(wt.path);
      const bookkeeping = path.join(cwd, '.git', 'worktrees', wtName);
      try { fs.rmSync(bookkeeping, { recursive: true, force: true }); } catch {}
    }
    pruned.push(wt.path);
  }
  // Final sweep for any remaining stale entries
  await _gitSafe(cwd, ['worktree', 'prune']);
  return { prunedPaths: pruned };
}

// Get a rich list of worktrees with status (Pillar 1). Read-only by default —
// pass `{ autoRecover: true }` to also create branches for detached-HEAD
// worktrees in the same call. Auto-recovery is gated to keep list polls
// side-effect-free.
async function listRichWorktrees(cwd, opts) {
  opts = opts || {};
  const mainBranch = opts.mainBranch || 'main';
  const autoRecover = opts.autoRecover === true;
  const worktrees = await listWorktrees(cwd);
  // The MAIN repo root is the first entry in `git worktree list` (isMain=true).
  // Don't use `git rev-parse --show-toplevel` here — that returns the *current
  // worktree's* root, which makes the canonical-path check incorrect when this
  // helper is called from a non-primary worktree.
  const repoRoot = (worktrees[0] && worktrees[0].path) || cwd;
  const mainRemote = await _mainRemoteStatus(repoRoot, mainBranch);

  // Process each worktree in parallel for speed.
  const enriched = await Promise.all(worktrees.map(async (wt) => {
    const isGhost = _isGhostPath(wt.path);
    const isCanonical = _isCanonicalPath(repoRoot, wt.path, wt.isMain);

    // Ghost worktrees can't have any of the live-status fields filled.
    if (isGhost) {
      return {
        ...wt, isGhost: true, isCanonical, ahead: 0, behind: 0, dirtyFiles: 0,
        unmergedCommits: 0, lastActivity: null, lastActivityRel: '',
        mainBranch,
        mainRemote,
        isFullySynced: false,
        safeCleanup: false,
        state: 'ghost',
        summary: 'Ghost worktree — corrupt or missing path. Click Prune to clean up.',
        recommendedAction: { kind: 'prune', label: 'Prune', tone: 'danger', reason: 'Git metadata points at a missing or corrupt path.' },
      };
    }

    // Detached-HEAD auto-recovery (Pillar 4b). Only runs when caller opts in;
    // surfacing detached-HEAD in the list is enough for read-only callers.
    let recoveredBranch = null;
    if (autoRecover && !wt.branch && !wt.isMain) {
      try { recoveredBranch = await recoverDetachedHead(wt.path); }
      catch (e) { console.error('[listRichWorktrees] auto-recover failed for', wt.path, e.message); }
      if (recoveredBranch) wt.branch = recoveredBranch;
    }

    // Gather status fields in parallel.
    const [revlist, statusOut, lastIso, unmergedOut] = await Promise.all([
      wt.branch ? _gitSafe(wt.path, ['rev-list', '--left-right', '--count', `${mainBranch}...${wt.branch}`]) : Promise.resolve(null),
      _gitSafeRaw(wt.path, ['status', '--porcelain=v1', '-uall', '-z']),
      _gitSafe(wt.path, ['log', '-1', '--format=%cI', 'HEAD']),
      wt.branch && !wt.isMain ? _gitSafe(wt.path, ['rev-list', '--count', `${mainBranch}..HEAD`]) : Promise.resolve('0'),
    ]);

    let ahead = 0, behind = 0;
    if (revlist) {
      const parts = revlist.split(/\s+/).filter(Boolean);
      if (parts.length >= 2) {
        behind = parseInt(parts[0], 10) || 0;
        ahead = parseInt(parts[1], 10) || 0;
      }
    }
    const status = _parsePorcelainStatusZ(statusOut || '');
    const dirtyFiles = status.dirtyFiles;
    const unmergedCommits = parseInt(unmergedOut, 10) || 0;

    const out = {
      ...wt, isGhost: false, isCanonical, ahead, behind, dirtyFiles, unmergedCommits,
      trackedDirtyFiles: status.trackedDirtyFiles,
      untrackedFiles: status.untrackedFiles,
      unmergedFiles: status.unmergedFiles,
      stagedFiles: status.stagedFiles,
      unstagedFiles: status.unstagedFiles,
      hasOnlyUntrackedFiles: status.hasOnlyUntrackedFiles,
      lastActivity: lastIso || null,
      lastActivityRel: _formatRelativeTime(lastIso),
      mainBranch,
      mainRemote,
      recoveredBranch,
    };
    out.state = _classifyState(out);
    out.isFullySynced = !out.isMain
      ? out.state === 'clean' && mainRemote.state === 'synced'
      : dirtyFiles === 0 && mainRemote.state === 'synced';
    out.safeCleanup = !out.isMain && out.state === 'clean' && dirtyFiles === 0 && unmergedCommits === 0;
    out.recommendedAction = _recommendedAction(out);
    out.summary = _buildSummary(out);
    return out;
  }));

  // Sort: ghosts first, then by actionability rank, then by branch name for
  // stability within a tier.
  enriched.sort((a, b) => {
    const ra = STATE_SORT_RANK[a.state] ?? 99;
    const rb = STATE_SORT_RANK[b.state] ?? 99;
    if (ra !== rb) return ra - rb;
    return String(a.branch || '').localeCompare(String(b.branch || ''));
  });

  return enriched;
}

// Lean single-worktree status for ONE path — the fast counterpart to
// listRichWorktrees(). Used by the on-turn-finish refresh so the sidebar
// dirty/commit badge updates in ~seconds instead of waiting on the 30s
// listRichWorktrees() fan-out (a documented CPU hog: ~4 git spawns PER worktree
// across ~40 worktrees). This does those ~4 git calls for just the one worktree.
// Computes only the fields the session worktree-status badge needs (live branch,
// ahead/behind vs main, dirty counts, unmerged commits, state). Best-effort:
// returns null if the path is missing or not a git worktree. mainRemote is a
// stub here (only meaningful for the primary worktree, which never shows the
// attention badge); callers needing remote state use listRichWorktrees().
async function richWorktreeStatusForPath(worktreePath, opts = {}) {
  const mainBranch = opts.mainBranch || 'main';
  if (!worktreePath || _isGhostPath(worktreePath)) return null;
  const branch = await _gitSafe(worktreePath, ['rev-parse', '--abbrev-ref', 'HEAD']);
  if (!branch) return null;
  const detached = branch === 'HEAD';
  const isMain = !detached && (branch === mainBranch || branch === 'main' || branch === 'master');
  const [revlist, statusOut, lastIso, unmergedOut] = await Promise.all([
    !detached ? _gitSafe(worktreePath, ['rev-list', '--left-right', '--count', `${mainBranch}...${branch}`]) : Promise.resolve(null),
    _gitSafeRaw(worktreePath, ['status', '--porcelain=v1', '-uall', '-z']),
    _gitSafe(worktreePath, ['log', '-1', '--format=%cI', 'HEAD']),
    (!isMain && !detached) ? _gitSafe(worktreePath, ['rev-list', '--count', `${mainBranch}..HEAD`]) : Promise.resolve('0'),
  ]);
  let ahead = 0, behind = 0;
  if (revlist) {
    const parts = revlist.split(/\s+/).filter(Boolean);
    if (parts.length >= 2) { behind = parseInt(parts[0], 10) || 0; ahead = parseInt(parts[1], 10) || 0; }
  }
  const status = _parsePorcelainStatusZ(statusOut || '');
  const out = {
    path: worktreePath,
    worktreeName: path.basename(worktreePath || ''),
    branch: detached ? '' : branch,
    head: null,
    isMain,
    isGhost: false,
    isCanonical: true,
    ahead, behind,
    dirtyFiles: status.dirtyFiles,
    trackedDirtyFiles: status.trackedDirtyFiles,
    untrackedFiles: status.untrackedFiles,
    unmergedFiles: status.unmergedFiles,
    stagedFiles: status.stagedFiles,
    unstagedFiles: status.unstagedFiles,
    hasOnlyUntrackedFiles: status.hasOnlyUntrackedFiles,
    unmergedCommits: parseInt(unmergedOut, 10) || 0,
    lastActivity: lastIso || null,
    lastActivityRel: _formatRelativeTime(lastIso),
    mainBranch,
    mainRemote: { branch: mainBranch, remote: null, ahead: 0, behind: 0, state: 'unknown' },
  };
  out.state = _classifyState(out);
  out.summary = _buildSummary(out);
  return out;
}

// Get the list of unmerged commits between main and a worktree branch
// (used by safe-delete and PR-body generation).
async function getUnmergedCommitList(cwd, branchName, baseBranch) {
  baseBranch = baseBranch || 'main';
  const out = await _gitSafe(cwd, ['log', '--format=%h %s', `${baseBranch}..${branchName}`]);
  if (!out) return [];
  return out.split('\n').filter(Boolean).map(line => {
    const i = line.indexOf(' ');
    return { sha: line.slice(0, i), subject: line.slice(i + 1) };
  });
}

// Push branch and open a PR via gh (Pillar 8). The push is the user's
// explicit intent (button click) — never invoke this without UI confirmation.
async function pushAndCreatePR(cwd, branchName, opts) {
  opts = opts || {};
  const base = opts.base || 'main';
  const repoRoot = (await _gitSafe(cwd, ['rev-parse', '--show-toplevel'])) || cwd;
  const worktrees = await listWorktrees(repoRoot);
  const wt = worktrees.find(w => w.branch === branchName);
  if (!wt) throw new Error(`Worktree not found for branch: ${branchName}`);

  // Check remote exists.
  const remotes = await _gitSafe(wt.path, ['remote']);
  if (!remotes || !remotes.split('\n').includes('origin')) {
    const err = new Error('No `origin` remote configured for this repository.');
    err.code = 'NO_REMOTE';
    throw err;
  }

  // Push (sets upstream).
  const pushOut = await new Promise((resolve, reject) => {
    execFile('git', ['push', '-u', 'origin', branchName], { cwd: wt.path, timeout: 60000 }, (err, stdout, stderr) => {
      if (err) return reject(new Error(`git push failed: ${stderr || err.message}`));
      resolve((stdout || '') + (stderr || ''));
    });
  });

  // Build title/body if not supplied.
  let title = opts.title;
  let body = opts.body;
  if (!title) {
    title = await _gitSafe(wt.path, ['log', '-1', '--format=%s', branchName]) || branchName;
  }
  if (!body) {
    const log = await _gitSafe(wt.path, ['log', `--format=- %s`, `${base}..${branchName}`]);
    body = log ? `## Summary\n\n${log}\n` : `Branch \`${branchName}\` opened.`;
  }

  // gh pr create. Do not let gh infer the repository from `origin`: in forked
  // or stale-remote checkouts that can point it at an inaccessible repo.
  const prCommand = await _buildGhPrCreateArgs(wt.path, branchName, { base, title, body });
  const ghOut = await new Promise((resolve, reject) => {
    execFile('gh', prCommand.args,
      { cwd: wt.path, timeout: 60000 }, (err, stdout, stderr) => {
        if (err) {
          const target = prCommand.baseSpec
            ? `${prCommand.baseSpec.ghRepo} from ${prCommand.head}`
            : `the repository inferred by gh from ${wt.path}`;
          return reject(new Error(`gh pr create failed for ${target}: ${stderr || err.message}`));
        }
        resolve((stdout || '').trim());
      });
  });
  // gh prints the PR URL on stdout
  const url = (ghOut.match(/https:\/\/github\.com\/[^\s]+/) || [null])[0];
  return { ok: true, pushed: true, url, raw: ghOut, pushOut };
}

module.exports = {
  git, getCommits, getCommitLog, getBranch, getDiffStat, getFullDiff, getStagedDiff, parseDiff,
  resolveMainBranch, getDefaultReviewBase, getDiffVsRef, getDiffStatVsRef, getDefaultDiffStat, getFileAtRef,
  listWorktrees, createWorktree, mergeWorktreePreCheck, mergeWorktree, commitWorktreeChanges, finishWorktree, removeWorktree, deleteBranch,
  // Rich-status + new operations
  listRichWorktrees, richWorktreeStatusForPath, syncWorktree, syncAllWorktrees, pruneGhosts, recoverDetachedHead, recoverWorktree,
  getUnmergedCommitList, pushAndCreatePR,
  // Internal helpers exposed for tests
  _classifyState, _buildSummary, _isGhostPath, _isCanonicalPath, STATE_SORT_RANK,
  _classifyMainRemote, _recommendedAction, _syncAllEligibility, _sameWorktreePath,
  _parseGitHubRemoteUrl, _githubRemoteSpec, _buildGhPrCreateArgs,
  _worktreeNamespace, _worktreeParentDir, _checkpointRefName, _checkpointRefSlug,
  _invalidateGitFactCache,
};
