editConflict

tcmts.cli.merge.editConflict · #ubeg3xt7 · term · runs on the server

uses node:path

tcm edit of conflicted names: a scratch file holding each one's two sides merged three-way from the merge base's, as tcm view shows each (a page: its Markdown), with git's conflict markers (ours, the base's and theirs, each labelled with its side) only where both changed the same lines. Saving it with tcm update, once the markers are gone, resolves the conflicts to what it holds, and re-derives what used either side's, once. target is the file, or how to name the default one (.tsx for JSX): it's named after the (first) name, so conflicts edited one after another each have their own. A file there already is written over only if replacing it loses nothing (it holds what's saved, as a file tcm update left is). What either side imports is merged, but for what nothing in the file uses (one side's alone, that the code merged doesn't use). Said: where it is, what each side changed (where in it), and whether, merged, it typechecks as tcm check checks it, or what it uses that's still conflicted.

import type { Codebase } from "#codebase/tcmts/cli/codebase";
import { fileName, formatOf, isBytes } from "#codebase/tcmts/cli/files";
import { pruneUnusedImports, withTheirCycles } from "#codebase/tcmts/cli/main";
import {
  conflictedHint, conflictedUses, mergeShown, oneImportEach, scratchText, silently, unsavedIn,
} from "#codebase/tcmts/cli/merge";
import type { Conflict, Stretch } from "#codebase/tcmts/cli/merge";
import { joinName } from "#codebase/tcmts/cli/mirror";
import { dim } from "#codebase/tcmts/cli/style";
import { scratchShown, shellWord, shownPath, UsageError } from "#codebase/tcmts/cli/ui";
import type { DefRecord } from "#codebase/tcmts/core/types";
import { groupImports } from "#codebase/tcmts/loader";
import fs from "node:fs";
import path_1 from "node:path";

export function editConflict(
  cb: Codebase,
  cs: Conflict[],
  target: string | ((jsx: boolean) => string),
  force: boolean,
): void {
  const label = (word: string, s: Conflict["sides"][number]) =>
    `${word}${s.label ? ` (${s.label})` : ""}`;
  const first = cs[0]!.path;
  const nsOf = (p: string) => p.slice(0, Math.max(0, p.lastIndexOf(".")));
  // (a file holding several is in the first's namespace: the others say where they go)
  const ns = nsOf(first);
  const merges = cs.map((c) => {
    const ours = c.sides.find((s) => s.side === "ours") ?? c.sides[0]!;
    const theirs = c.sides.find((s) => s !== ours && s.side !== "ours")!;
    const [o, t] = [ours, theirs].map((s) => cb.store.getDef(s.hash)!) as [DefRecord, DefRecord];
    const b = c.base ? cb.store.getDef(c.base) : undefined;
    if (o.kind !== t.kind)
      throw new UsageError(
        `${c.path}'s definitions are a ${o.kind} and a ${t.kind}: \`tcm resolve ${c.path}\` keeps one`,
      );
    if (o.kind === "file" && [o, t, b].some((r) => r && isBytes(r)))
      throw new UsageError(
        `${c.path} isn't text: \`tcm resolve ${c.path} --ours\` (or \`--theirs\`) keeps one`,
      );
    if (o.kind === "file" && cs.length > 1)
      throw new UsageError(`${c.path} is a file kept as written: edit it on its own`);
    const merged = mergeShown(
      cb,
      c.path,
      b,
      o,
      t,
      {
        ours: label("ours", ours),
        base: b ? "base" : "base (not known: each side whole)",
        theirs: label("theirs", theirs),
      },
      ns,
    );
    return { c, ours, theirs, o, t, b, merged };
  });
  const { o } = merges[0]!;
  const kept = o.kind === "file";
  const jsx = merges.some((m) => !!(m.o.jsx || m.t.jsx));
  const file =
    typeof target === "string"
      ? target
      : kept
        ? path_1.join(
            cb.sessionDir ?? process.cwd(),
            fileName(first.slice(first.lastIndexOf(".") + 1), o),
          )
        : path_1.join(path_1.dirname(target(jsx)), `${first}${jsx ? ".tsx" : ".ts"}`);
  // the file's text: its namespace (or path) line, what they import (but one another), then each one's code, and
  // the line each one's starts at
  const compose = (ms: typeof merges) => {
    const here = new Set(ms.map((m) => m.c.path));
    // (one definition named twice, each side's code saying it by its own name, is imported once)
    const same = (a: string, b: string) => {
      const at = new Set(cb.store.hashesAt(a));
      return a === b || cb.store.hashesAt(b).some((h) => at.has(h));
    };
    const imports = groupImports(
      oneImportEach([...new Set(ms.flatMap((m) => m.merged.imports ?? []))], same).filter(
        (line) => {
          const m =
            /^import (?:type )?\{ ([\w$]+)(?: as [\w$]+)? \} from "#codebase(?:\/([^"]*))?";$/.exec(
              line,
            );
          return !m || !here.has(joinName((m[2] ?? "").split("/").join("."), m[1]!));
        },
      ),
    );
    const lines = kept
      ? formatOf(o) === "md"
        ? [`<!-- tcm: path ${first} -->`]
        : []
      : [
          ...(ns ? [`// tcm: namespace ${ns}`] : []),
          ...imports.flatMap((i) => i.split("\n")),
          ...(imports.length ? [""] : []),
        ];
    const at = ms.map((m) => {
      if (m !== ms[0] && lines.at(-1) !== "") lines.push("");
      const from = lines.length;
      lines.push(...m.merged.body);
      return from;
    });
    // (imports nothing uses taken out: lines above all the code, so it all moves up as many)
    const whole = lines.join("\n");
    const text = kept ? whole : pruneUnusedImports(file, whole).text;
    const by = whole.split("\n").length - text.split("\n").length;
    return { text, at: at.map((n) => n - by) };
  };
  const { text, at } = compose(merges);
  const where = shownPath(file);
  // (how a command names it: in a session's own directory, by its name alone)
  const named = shellWord(scratchShown(file, cb.sessionDir));
  const names = cs.map((c) => c.path).join(" ");
  // (a file holding other work isn't written over: one holding only what's saved, as `tcm update` leaves one, is)
  const there = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
  if (
    there.trim() &&
    there !== text &&
    !force &&
    // (nor is one written so for one of them alone, untouched: `tcm edit` of it, then of it with what it uses)
    !merges.some((m) => merges.length > 1 && nsOf(m.c.path) === ns && compose([m]).text === there)
  ) {
    const inTheWay = kept
      ? cb.store
          .hashesAt(first, "file")
          .some(
            (h) =>
              scratchText(cb, cb.store.getDef(h)!, first) ===
              there.replace(/^<!-- tcm: path \S+ -->\n/, ""),
          )
        ? []
        : [first]
      : unsavedIn(cb, file, there);
    if (inTheWay.length)
      throw new UsageError(
        `${where} holds work that isn't saved (${inTheWay.join(", ")}): save it first (\`tcm update ${named}\`), edit ${names} into another file (--file), or --force to replace it`,
      );
  }
  fs.writeFileSync(file, text);
  console.log(`wrote ${cs.map((c) => c.path).join(", ")} to ${where}`);
  // what each side changed, and where that is in the file
  const lineList = (ss: Stretch[], from: number) => {
    const ls = ss.map((s) =>
      s.lines > 1
        ? `${from + s.at + 1}–${from + s.at + s.lines}`
        : s.lines
          ? String(from + s.at + 1)
          : `${from + s.at + 1} (taken out before it)`,
    );
    return `${ls.length > 1 || (ss[0]?.lines ?? 0) > 1 ? "lines" : "line"} ${ls.join(", ")}`;
  };
  for (const [i, m] of merges.entries()) {
    const { stretches, changed, conflicts } = m.merged;
    console.log(
      `  ${m.c.path}: ${label("ours", m.ours)} and ${label("theirs", m.theirs)}, merged from ${m.b ? "the base" : "nothing (the base isn't known)"}`,
    );
    for (const side of ["ours", "theirs"] as const) {
      const ss = stretches.filter((s) => s.by === side || s.by === "both");
      console.log(
        `    ${side}: ${ss.length ? `+${changed[side].added} −${changed[side].removed} lines, at ${lineList(ss, at[i]!)}` : "no lines changed"}`,
      );
    }
    const both = stretches.filter((s) => s.by === "both");
    const alike = both.filter((s) => !s.conflict);
    console.log(
      `    both: ${
        both.length
          ? [
              conflicts
                ? `${conflicts} stretch${conflicts > 1 ? "es" : ""} changed differently, marked at ${lineList(
                    both.filter((s) => s.conflict),
                    at[i]!,
                  )}`
                : "",
              alike.length ? `${alike.length} changed alike, taken once` : "",
            ]
              .filter(Boolean)
              .join("; ")
          : "no stretch changed by both"
      }`,
    );
    if (m.c.sides.length > 2)
      console.log(
        dim(
          `    (${m.c.path} holds ${m.c.sides.length} definitions: this merges two; saving replaces them all)`,
        ),
      );
  }
  const conflicts = merges.reduce((n, m) => n + m.merged.conflicts, 0);
  const save = `\`tcm update ${named}${kept && formatOf(o) !== "md" ? ` --into ${ns}` : ""}\``;
  const resolves = `saving it resolves ${cs.length > 1 ? "the conflicts" : "the conflict"}`;
  // what it uses that's still conflicted: it can't typecheck until that's settled
  const uses = kept ? [] : conflictedUses(cb, file, text);
  if (conflicts)
    console.log(
      `${conflicts} conflict${conflicts > 1 ? "s" : ""} marked where both changed the same lines (<<<<<<< ours, ||||||| base, =======, >>>>>>> theirs, each a line of its own). Keep what each should, take the markers out (\`tcm check ${named}\` finds any left), then ${save}: ${resolves}`,
    );
  else if (uses.length)
    console.log(
      `Merged with no lines both changed, but it can't typecheck yet: ${conflictedHint(
        uses,
        cs.map((c) => c.path),
      )}`,
    );
  else if (kept) console.log(`Merged with no lines both changed: ${save} (${resolves})`);
  else if (silently(() => withTheirCycles(cb, file, undefined)))
    console.log(
      `Merged with no lines both changed, and it typechecks: ${save} (${resolves}; \`tcm check\` again if you change it)`,
    );
  else
    console.log(
      `Merged with no lines both changed, but it doesn't typecheck: \`tcm check ${named}\` says why. Fix it, then ${save}: ${resolves}`,
    );
  if (conflicts && uses.length)
    console.log(
      `  It ${conflictedHint(
        uses,
        cs.map((c) => c.path),
      )}`,
    );
}

uses

used by