OXYGENHUB

Commit box: amend mode, previous messages, signing indicator, and a warning before committing conflict markers; 0.9.0 44bfd5adfc9a

closes #2
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 16:08 UTC
Signature
✔ signed by Andy McCall
Hash
44bfd5adfc9a3fd6a39086ab97363e33f29513a120b05c55a7f5641cd4b5d479
Depends on
25103dddd993 35d56b5803d9 9020cf612749 ab81294a9ba9 b4626290e660 bed495a96408 f6bda77ecb9f
Depended on by
dac6876e4d08 f532c4266f21 cdcffe39dd9f 1f7fc1e3ae4a
In bookmarks
main
In tags
v0.16.0 v0.16.1 v0.17.0 v0.17.1 v0.18.0
Edits
28 atoms, 2 tree operations, 8 files

Browse the tree at this change

added src/commitbox.ts +90 −0
1 +
// The commit box's logic (spec §5.1), kept free of the vscode module so it
2 +
// can be tested: signing state from `oxy identity show`, the placeholder
3 +
// that shows it, message history, and conflict marker detection.
4 +
5 +
export interface SigningInfo {
6 +
  /** `Name <email>`, or empty without an identity. */
7 +
  who: string;
8 +
  /** Whether the secret key is in the key directory. */
9 +
  secret: boolean;
10 +
  /** `change`: every commit is signed; `seal`: changes are sealed at push. */
11 +
  mode: "change" | "seal";
12 +
}
13 +
14 +
/** Parse `oxy --plain identity show`; `null` when no identity is configured. */
15 +
export function parseIdentityShow(text: string): SigningInfo | null {
16 +
  const head = text.match(/^#\s*identity\s+(.+)$/m);
17 +
  if (!head) {
18 +
    return null;
19 +
  }
20 +
  const secretLine = text.match(/^\s*secret\s+(.+)$/m)?.[1] ?? "";
21 +
  const signingLine = text.match(/^\s*signing\s+(.+)$/m)?.[1] ?? "";
22 +
  return {
23 +
    who: head[1].trim(),
24 +
    secret: /available/.test(secretLine),
25 +
    mode: /core\.sign\s*=\s*change/.test(signingLine) ? "change" : "seal",
26 +
  };
27 +
}
28 +
29 +
/** A short name for the placeholder: the name without the address. */
30 +
function shortName(who: string): string {
31 +
  return who.replace(/\s*<[^>]*>\s*$/, "").trim() || who;
32 +
}
33 +
34 +
/** How the next commit will be authenticated, for the commit box. */
35 +
export function signingLabel(info: SigningInfo | null | undefined): string {
36 +
  if (info === undefined) {
37 +
    return "";
38 +
  }
39 +
  if (info === null) {
40 +
    return "⚠ unsigned: no identity (oxy identity create)";
41 +
  }
42 +
  if (!info.secret) {
43 +
    return `⚠ unsigned: the key for ${shortName(info.who)} is missing`;
44 +
  }
45 +
  return info.mode === "change" ? `✔ signed as ${shortName(info.who)}` : `◈ sealed at push as ${shortName(info.who)}`;
46 +
}
47 +
48 +
/** The commit box placeholder for the current mode. */
49 +
export function placeholder(opts: { amend: boolean; resolution: boolean; signing: string }): string {
50 +
  const what = opts.amend ? "Amend the last change: new message, or empty to keep it" : opts.resolution ? "Describe the resolution" : "Message";
51 +
  const key = opts.amend ? "Ctrl+Enter to amend" : "Ctrl+Enter to commit";
52 +
  return `${what} (${key})${opts.signing ? ` · ${opts.signing}` : ""}`;
53 +
}
54 +
55 +
/** Recent commit messages, newest first, without duplicates. */
56 +
export class MessageHistory {
57 +
  static readonly LIMIT = 30;
58 +
  private items: string[];
59 +
60 +
  constructor(items: readonly string[] = []) {
61 +
    this.items = items.filter((m) => m.trim()).slice(0, MessageHistory.LIMIT);
62 +
  }
63 +
64 +
  add(message: string): void {
65 +
    const m = message.trim();
66 +
    if (!m) {
67 +
      return;
68 +
    }
69 +
    this.items = [m, ...this.items.filter((x) => x !== m)].slice(0, MessageHistory.LIMIT);
70 +
  }
71 +
72 +
  get entries(): readonly string[] {
73 +
    return this.items;
74 +
  }
75 +
}
76 +
77 +
/**
78 +
 * Line numbers (1-based) that open or close a conflict: Oxygen's markers
79 +
 * (`<<<<<<< oxy conflict …`) and Git's alike, since both begin with seven
80 +
 * angle brackets.
81 +
 */
82 +
export function conflictMarkerLines(text: string): number[] {
83 +
  const out: number[] = [];
84 +
  text.split("\n").forEach((l, i) => {
85 +
    if (/^(<{7}|>{7})( |\r?$)/.test(l)) {
86 +
      out.push(i + 1);
87 +
    }
88 +
  });
89 +
  return out;
90 +
}
added test/commitbox.test.mjs +49 −0
1 +
import { test } from "node:test";
2 +
import assert from "node:assert/strict";
3 +
import { MessageHistory, conflictMarkerLines, parseIdentityShow, placeholder, signingLabel } from "../out/commitbox.mjs";
4 +
5 +
const SIGNED = `# identity Andy McCall <[email protected]>
6 +
  key      f1add784
7 +
  created  2026-09-26 18:12:11Z (2 days ago)
8 +
  secret   * available
9 +
  signing  every change (core.sign = change)
10 +
`;
11 +
12 +
test("signing state from oxy identity show", () => {
13 +
  const s = parseIdentityShow(SIGNED);
14 +
  assert.deepEqual(s, { who: "Andy McCall <[email protected]>", secret: true, mode: "change" });
15 +
  assert.equal(signingLabel(s), "✔ signed as Andy McCall");
16 +
  const sealed = parseIdentityShow(SIGNED.replace("every change (core.sign = change)", "at push via seals (core.sign = seal)"));
17 +
  assert.equal(signingLabel(sealed), "◈ sealed at push as Andy McCall");
18 +
  const keyless = parseIdentityShow(SIGNED.replace("* available", "x not found in the key directory"));
19 +
  assert.match(signingLabel(keyless), /unsigned: the key for Andy McCall is missing/);
20 +
  assert.equal(parseIdentityShow("No identity configured; run `oxy identity create`."), null);
21 +
  assert.match(signingLabel(null), /unsigned: no identity/);
22 +
  assert.equal(signingLabel(undefined), "", "not known yet");
23 +
});
24 +
25 +
test("placeholder follows the mode", () => {
26 +
  assert.equal(placeholder({ amend: false, resolution: false, signing: "✔ signed as A" }), "Message (Ctrl+Enter to commit) · ✔ signed as A");
27 +
  assert.match(placeholder({ amend: true, resolution: false, signing: "" }), /^Amend the last change.*Ctrl\+Enter to amend\)$/);
28 +
  assert.match(placeholder({ amend: false, resolution: true, signing: "" }), /^Describe the resolution/);
29 +
});
30 +
31 +
test("message history is newest first, unique and bounded", () => {
32 +
  const h = new MessageHistory(["b", "", "a"]);
33 +
  h.add("  c  ");
34 +
  h.add("a");
35 +
  h.add("");
36 +
  assert.deepEqual(h.entries, ["a", "c", "b"]);
37 +
  for (let i = 0; i < 40; i++) {
38 +
    h.add(`m${i}`);
39 +
  }
40 +
  assert.equal(h.entries.length, MessageHistory.LIMIT);
41 +
  assert.equal(h.entries[0], "m39");
42 +
});
43 +
44 +
test("conflict markers: Oxygen's and Git's, not look-alikes", () => {
45 +
  const oxy = ['a', '<<<<<<< oxy conflict 1 of 1, side 1 of 2: change k7m2p9 "x"', "one", '======= oxy conflict 1 of 1, side 2 of 2: change q3x8w1 "y"', "two", ">>>>>>> oxy conflict 1 of 1 end", ""].join("\n");
46 +
  assert.deepEqual(conflictMarkerLines(oxy), [2, 6]);
47 +
  assert.deepEqual(conflictMarkerLines("x\n<<<<<<< HEAD\ny\n=======\nz\n>>>>>>> feature\r\n"), [2, 6]);
48 +
  assert.deepEqual(conflictMarkerLines("<<<<<<<< eight\n  <<<<<<< indented\na <<<<<<< b\n"), []);
49 +
});
modified CHANGELOG.md +7 −0
1 1
# Changelog
2 2
3 +
## 0.9.0
4 +
5 +
- **Amend mode** (the pencil in the Source Control title bar): Ctrl+Enter amends the last change, and the box starts from its message.
6 +
- **Previous Commit Messages…** puts a recent message back in the box. Messages are remembered per repository as you commit.
7 +
- **Signing indicator**: the box says whether the next commit will be signed, sealed at push, or unsigned (no identity, or its key missing).
8 +
- **Conflict marker warning**: committing or amending files that still hold conflict markers (Oxygen's or Git's) asks first, and can open the first one.
9 +
3 10
## 0.8.0
4 11
5 12
- The History graph rewrites history:
…
49 unchanged lines
modified README.md +3 −0
…
36 unchanged lines
37 37
- **Status bar**: bookmark, working copy and state; click to switch, create,
38 38
  merge or delete bookmarks; a sync counter with Sync, Fetch, Pull, Push and
39 39
  Push Bookmark.
40 +
- **Commit box**: amend mode, previous messages, a note of whether the next
41 +
  commit is signed, sealed at push or unsigned, and a warning before
42 +
  committing leftover conflict markers.
40 43
- **Views**: Oxygen Operations (undo to any point), Oxygen Bisect
41 44
  (start, good, bad, skip, run, reset), Oxygen Trust (policy, signatures),
42 45
  and a History graph with per-change actions (diff, check out, describe,
…
15 unchanged lines
modified package.json +35 −2
…
1 unchanged lines
2 2
  "name": "oxygen-vscode",
3 3
  "displayName": "Oxygen",
4 4
  "description": "Oxygen version control: source control view, diffs, decorations, bookmarks and history for oxy repositories",
5 -
  "version": "0.8.0",
5 +
  "version": "0.9.0",
6 6
  "publisher": "oxygen",
7 7
  "license": "MIT OR Apache-2.0",
8 8
  "icon": "assets/images/oxygen.png",
…
301 unchanged lines
310 310
        "title": "Rebase onto\u2026",
311 311
        "category": "Oxygen",
312 312
        "icon": "$(git-pull-request-go-to-changes)"
313 +
      },
314 +
      {
315 +
        "command": "oxygen.amendMode",
316 +
        "title": "Amend Mode",
317 +
        "category": "Oxygen",
318 +
        "icon": "$(edit)"
319 +
      },
320 +
      {
321 +
        "command": "oxygen.leaveAmendMode",
322 +
        "title": "Leave Amend Mode",
323 +
        "category": "Oxygen",
324 +
        "icon": "$(close)"
325 +
      },
326 +
      {
327 +
        "command": "oxygen.messageHistory",
328 +
        "title": "Previous Commit Messages\u2026",
329 +
        "category": "Oxygen",
330 +
        "icon": "$(history)"
313 331
      }
314 332
    ],
315 333
    "views": {
…
149 unchanged lines
465 483
          "command": "oxygen.openOnRemote",
466 484
          "when": "scmProvider == oxygen",
467 485
          "group": "navigation@9"
486 +
        },
487 +
        {
488 +
          "command": "oxygen.amendMode",
489 +
          "group": "navigation@4",
490 +
          "when": "scmProvider == oxygen && !oxygen.amending"
491 +
        },
492 +
        {
493 +
          "command": "oxygen.leaveAmendMode",
494 +
          "group": "navigation@4",
495 +
          "when": "scmProvider == oxygen && oxygen.amending"
496 +
        },
497 +
        {
498 +
          "command": "oxygen.messageHistory",
499 +
          "group": "2_commit@4",
500 +
          "when": "scmProvider == oxygen"
468 501
        }
469 502
      ],
470 503
      "editor/title": [
…
244 unchanged lines
715 748
    "build": "esbuild src/extension.ts --bundle --platform=node --format=cjs --external:vscode --outfile=dist/extension.js --sourcemap",
716 749
    "watch": "npm run build -- --watch",
717 750
    "typecheck": "tsc --noEmit -p .",
718 -
    "pretest": "esbuild src/oxy.ts --bundle --platform=node --format=esm --outfile=out/oxy.mjs && esbuild src/diffutil.ts --bundle --platform=node --format=esm --outfile=out/diffutil.mjs && esbuild src/markers.ts --bundle --platform=node --format=esm --outfile=out/markers.mjs && esbuild src/api.ts --bundle --platform=node --format=esm --outfile=out/api.mjs",
751 +
    "pretest": "esbuild src/oxy.ts --bundle --platform=node --format=esm --outfile=out/oxy.mjs && esbuild src/diffutil.ts --bundle --platform=node --format=esm --outfile=out/diffutil.mjs && esbuild src/markers.ts --bundle --platform=node --format=esm --outfile=out/markers.mjs && esbuild src/api.ts --bundle --platform=node --format=esm --outfile=out/api.mjs && esbuild src/commitbox.ts --bundle --platform=node --format=esm --outfile=out/commitbox.mjs",
719 752
    "test": "node --test \"test/**/*.test.mjs\"",
720 753
    "check": "npm run typecheck && npm run build && npm test",
721 754
    "package": "vsce package --no-dependencies"
…
9 unchanged lines
modified src/extension.ts +177 −4
…
2 unchanged lines
3 3
4 4
import * as vscode from "vscode";
5 5
import { OxygenAPI, RemoteProvider, RemoteTarget, RepositoryHandle, pickProvider, repositoryFor as rootFor } from "./api";
6 -
import { basename } from "node:path";
6 +
import { basename, join } from "node:path";
7 7
import { Oxy, OxyError, ShowInfo, findBinary } from "./oxy";
8 8
import { Repository, OxyResourceState, SubrepoState } from "./repository";
9 9
import { BisectProvider, TrustProvider } from "./panels";
…
2 unchanged lines
12 12
import { OperationItem, OperationsProvider, timeAgo } from "./operations";
13 13
import { BlameController } from "./blame";
14 14
import { HistoryPanel } from "./history";
15 +
import { MessageHistory, conflictMarkerLines, placeholder, signingLabel } from "./commitbox";
16 +
import { readFile, stat } from "node:fs/promises";
15 17
import { ConflictCodeLensProvider, acceptSides, keepCandidate, markResolved } from "./conflicts";
16 18
17 19
const MIN_VERSION = { format: 2, protocol: 1 };
…
10 unchanged lines
28 30
const repositories = new Map<string, Repository>();
29 31
30 32
export async function activate(context: vscode.ExtensionContext): Promise<OxygenAPI | undefined> {
33 +
  extensionContext = context;
31 34
  output = vscode.window.createOutputChannel("Oxygen");
32 35
  context.subscriptions.push(output);
33 36
  output.appendLine(`Oxygen extension ${context.extension.packageJSON.version} activating; folders: ${(vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath).join(", ") || "(none)"}`);
…
47 unchanged lines
81 84
    vscode.commands.registerCommand("oxygen.history.split", (ctx?: { root?: string; hash?: string }) => historyContext(ctx, "split")),
82 85
    vscode.commands.registerCommand("oxygen.history.squash", (ctx?: { root?: string; hash?: string }) => historyContext(ctx, "squash")),
83 86
    vscode.commands.registerCommand("oxygen.history.rebase", (ctx?: { root?: string; hash?: string }) => historyContext(ctx, "rebase")),
87 +
    vscode.commands.registerCommand("oxygen.amendMode", (arg?: Repository | vscode.SourceControl) => toggleAmend(arg, true)),
88 +
    vscode.commands.registerCommand("oxygen.leaveAmendMode", (arg?: Repository | vscode.SourceControl) => toggleAmend(arg, false)),
89 +
    vscode.commands.registerCommand("oxygen.messageHistory", (arg?: Repository | vscode.SourceControl) => messageHistory(arg)),
84 90
    vscode.commands.registerCommand("oxygen.undo", (arg?: Repository | vscode.SourceControl) => undo(arg, 1)),
85 91
    vscode.commands.registerCommand("oxygen.undoTo", (item?: OperationItem) => item && undo(item.repository, item.stepsBack)),
86 92
    vscode.commands.registerCommand("oxygen.refreshOperations", () => operations.refresh()),
…
186 unchanged lines
273 279
        bisectView.refresh();
274 280
        trustView.refresh();
275 281
        // The commit action reads as a resolution while conflicts are resolved but uncommitted.
276 -
        const resolved = status.conflicted && status.conflicts.length > 0 && status.conflicts.every((c) => c.resolved === true);
277 -
        repo.sourceControl.acceptInputCommand = { command: "oxygen.commit", title: resolved ? "Commit resolution" : "Commit", arguments: [repo] };
278 -
        repo.sourceControl.inputBox.placeholder = resolved ? "Describe the resolution (Ctrl+Enter to commit)" : "Message (Ctrl+Enter to commit)";
282 +
        box(repo).resolution = status.conflicted && status.conflicts.length > 0 && status.conflicts.every((c) => c.resolved === true);
283 +
        void refreshSigning(repo);
284 +
        updateBox(repo);
279 285
      });
280 286
      repositories.set(root, repo);
281 287
      repo.onDidChangeStatus(() => onDidChangeStatusEmitter.fire(handleOf(repo)));
…
39 unchanged lines
321 327
  const repo = repositoryFor(arg);
322 328
  if (!repo) {
323 329
    void vscode.window.showErrorMessage("Oxygen: no repository for the active file.");
330 +
    return;
331 +
  }
332 +
  if (box(repo).amend) {
333 +
    await amend(repo);
324 334
    return;
325 335
  }
326 336
  let message = repo.sourceControl.inputBox.value.trim();
…
3 unchanged lines
330 340
  if (!message) {
331 341
    return;
332 342
  }
343 +
  if (!(await markersOk(repo))) {
344 +
    return;
345 +
  }
333 346
  try {
334 347
    await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Committing" }, () => repo.oxy.commit(repo.root, message));
348 +
    remember(repo, message);
335 349
    repo.sourceControl.inputBox.value = "";
336 350
    await repo.refresh();
337 351
  } catch (e) {
…
45 unchanged lines
383 397
  const repo = states[0].repository;
384 398
  const message = await askMessage(repo, `Commit ${states.length === 1 ? basename(states[0].path) : `${states.length} files`}`);
385 399
  if (!message) {
400 +
    return;
401 +
  }
402 +
  if (!(await markersOk(repo, states.map((s) => s.path)))) {
386 403
    return;
387 404
  }
388 405
  try {
389 406
    await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Committing" }, () => repo.oxy.commit(repo.root, message, states.map((s) => s.path)));
407 +
    remember(repo, message);
390 408
    repo.sourceControl.inputBox.value = "";
391 409
    await repo.refresh();
392 410
  } catch (e) {
…
36 unchanged lines
429 447
  }
430 448
  try {
431 449
    await repo.oxy.commitHunks(repo.root, message, picks.map((p) => p.hunk.index));
450 +
    remember(repo, message);
432 451
    repo.sourceControl.inputBox.value = "";
433 452
    await repo.refresh();
434 453
  } catch (e) {
…
7 unchanged lines
442 461
    return;
443 462
  }
444 463
  const message = repo.sourceControl.inputBox.value.trim();
464 +
  if (!(await markersOk(repo))) {
465 +
    return;
466 +
  }
445 467
  try {
446 468
    await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Amending" }, () => repo.oxy.amend(repo.root, message || undefined));
469 +
    remember(repo, message);
447 470
    repo.sourceControl.inputBox.value = "";
471 +
    setAmend(repo, false);
448 472
    await repo.refresh();
449 473
  } catch (e) {
450 474
    showError(e);
…
438 unchanged lines
889 913
  } catch (e) {
890 914
    showError(e);
891 915
  }
916 +
}
917 +
918 +
// ------------------------------------------------------ commit box (V7)
919 +
920 +
interface BoxState {
921 +
  amend: boolean;
922 +
  resolution: boolean;
923 +
  signing: string;
924 +
  checkedMs: number;
925 +
}
926 +
927 +
const boxes = new Map<string, BoxState>();
928 +
let extensionContext: vscode.ExtensionContext | undefined;
929 +
930 +
function box(repo: Repository): BoxState {
931 +
  let b = boxes.get(repo.root);
932 +
  if (!b) {
933 +
    b = { amend: false, resolution: false, signing: "", checkedMs: 0 };
934 +
    boxes.set(repo.root, b);
935 +
  }
936 +
  return b;
937 +
}
938 +
939 +
/** Placeholder and the Ctrl+Enter action follow amend mode, resolution and signing. */
940 +
function updateBox(repo: Repository): void {
941 +
  const b = box(repo);
942 +
  repo.sourceControl.acceptInputCommand = { command: "oxygen.commit", title: b.amend ? "Amend" : b.resolution ? "Commit resolution" : "Commit", arguments: [repo] };
943 +
  repo.sourceControl.inputBox.placeholder = placeholder(b);
944 +
}
945 +
946 +
/** Re-read how commits will be signed, at most every 30 seconds. */
947 +
async function refreshSigning(repo: Repository, force = false): Promise<void> {
948 +
  const b = box(repo);
949 +
  if (!force && Date.now() - b.checkedMs < 30_000) {
950 +
    return;
951 +
  }
952 +
  b.checkedMs = Date.now();
953 +
  try {
954 +
    b.signing = signingLabel(await repo.oxy.identity(repo.root));
955 +
  } catch {
956 +
    b.signing = "";
957 +
  }
958 +
  updateBox(repo);
959 +
}
960 +
961 +
function setAmend(repo: Repository, on: boolean): void {
962 +
  box(repo).amend = on;
963 +
  void vscode.commands.executeCommand("setContext", "oxygen.amending", [...boxes.values()].some((b) => b.amend));
964 +
  updateBox(repo);
965 +
}
966 +
967 +
async function toggleAmend(arg: Repository | vscode.SourceControl | undefined, on: boolean): Promise<void> {
968 +
  const repo = repositoryFor(arg);
969 +
  if (!repo) {
970 +
    return;
971 +
  }
972 +
  setAmend(repo, on);
973 +
  if (on && !repo.sourceControl.inputBox.value.trim()) {
974 +
    // Start from the message being amended.
975 +
    try {
976 +
      const last = (await repo.oxy.log(repo.root, 2)).changes.find((c) => !c.working_copy);
977 +
      if (last) {
978 +
        repo.sourceControl.inputBox.value = last.message;
979 +
      }
980 +
    } catch {
981 +
      // An empty box keeps the message anyway.
982 +
    }
983 +
  }
984 +
}
985 +
986 +
function historyOf(repo: Repository): MessageHistory {
987 +
  return new MessageHistory(extensionContext?.workspaceState.get<string[]>(`oxygen.messages:${repo.root}`) ?? []);
988 +
}
989 +
990 +
function remember(repo: Repository, message: string): void {
991 +
  const h = historyOf(repo);
992 +
  h.add(message);
993 +
  void extensionContext?.workspaceState.update(`oxygen.messages:${repo.root}`, h.entries);
994 +
}
995 +
996 +
async function messageHistory(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
997 +
  const repo = repositoryFor(arg);
998 +
  if (!repo) {
999 +
    return;
1000 +
  }
1001 +
  const entries = historyOf(repo).entries;
1002 +
  if (entries.length === 0) {
1003 +
    void vscode.window.showInformationMessage("Oxygen: no messages yet; they are remembered as you commit.");
1004 +
    return;
1005 +
  }
1006 +
  const pick = await vscode.window.showQuickPick(
1007 +
    entries.map((m) => ({ label: m.split("\n")[0], detail: m.includes("\n") ? m.split("\n").slice(1).join(" ").trim().slice(0, 160) : undefined, message: m })),
1008 +
    { title: "Previous commit messages", placeHolder: "Put one back in the commit box", matchOnDetail: true },
1009 +
  );
1010 +
  if (pick) {
1011 +
    repo.sourceControl.inputBox.value = pick.message;
1012 +
  }
1013 +
}
1014 +
1015 +
/**
1016 +
 * Warn before committing files that still hold conflict markers: those oxy
1017 +
 * reports (markers left in a conflicted file) and any text file whose lines
1018 +
 * open or close a conflict. `paths` limits the check to what is committed.
1019 +
 */
1020 +
async function markersOk(repo: Repository, paths?: string[]): Promise<boolean> {
1021 +
  const status = repo.status;
1022 +
  if (!status) {
1023 +
    return true;
1024 +
  }
1025 +
  const found: { path: string; line?: number }[] = [];
1026 +
  for (const p of status.paths) {
1027 +
    if (p.status === "Removed" || (paths && !paths.includes(p.path))) {
1028 +
      continue;
1029 +
    }
1030 +
    if (p.status === "ConflictEdited") {
1031 +
      found.push({ path: p.path });
1032 +
      continue;
1033 +
    }
1034 +
    try {
1035 +
      const file = join(repo.root, p.path);
1036 +
      if ((await stat(file)).size > 4 * 1024 * 1024) {
1037 +
        continue;
1038 +
      }
1039 +
      const text = (await readFile(file)).toString("utf8");
1040 +
      const lines = conflictMarkerLines(text);
1041 +
      if (lines.length > 0) {
1042 +
        found.push({ path: p.path, line: lines[0] });
1043 +
      }
1044 +
    } catch {
1045 +
      // Unreadable or gone: nothing to check.
1046 +
    }
1047 +
  }
1048 +
  if (found.length === 0) {
1049 +
    return true;
1050 +
  }
1051 +
  const list = found.slice(0, 5).map((f) => (f.line ? `${f.path} (line ${f.line})` : f.path)).join(", ") + (found.length > 5 ? ` and ${found.length - 5} more` : "");
1052 +
  const pick = await vscode.window.showWarningMessage(
1053 +
    `Conflict markers remain in ${list}. Commit anyway?`,
1054 +
    { modal: true, detail: "Committing records the marker lines as ordinary text." },
1055 +
    "Commit anyway",
1056 +
    `Open ${basename(found[0].path)}`,
1057 +
  );
1058 +
  if (pick?.startsWith("Open ")) {
1059 +
    const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(join(repo.root, found[0].path)));
1060 +
    const line = Math.max(0, (found[0].line ?? 1) - 1);
1061 +
    await vscode.window.showTextDocument(doc, { selection: new vscode.Range(line, 0, line, 0) });
1062 +
    return false;
1063 +
  }
1064 +
  return pick === "Commit anyway";
892 1065
}
893 1066
894 1067
async function fileHistory(uri: vscode.Uri | undefined): Promise<void> {
…
353 unchanged lines
modified src/oxy.ts +6 −0
…
2 unchanged lines
3 3
// the vscode API so it can be tested with plain Node.
4 4
5 5
import { execFile } from "node:child_process";
6 +
import { SigningInfo, parseIdentityShow } from "./commitbox";
6 7
import { existsSync } from "node:fs";
7 8
import { homedir } from "node:os";
8 9
import { join } from "node:path";
…
236 unchanged lines
245 246
246 247
  abandon(root: string, change: string): Promise<string> {
247 248
    return this.check(root, ["abandon", change]).then((r) => r.stdout.toString("utf8").trim());
249 +
  }
250 +
251 +
  /** The configured identity and how commits are signed; `null` without one. */
252 +
  identity(root: string): Promise<SigningInfo | null> {
253 +
    return this.check(root, ["identity", "show"]).then((r) => parseIdentityShow(r.stdout.toString("utf8")));
248 254
  }
249 255
250 256
  /** Split a head: the edits to `paths` become a separate first change. Returns oxy's summary line. */
…
242 unchanged lines
modified test/oxy.test.mjs +15 −0
…
180 unchanged lines
181 181
  // Rebase onto a state that already has the change is refused, with oxy's reason.
182 182
  await assert.rejects(oxy.rebase(root, `#${after.hash.slice(0, 12)}`, ["main"]), /already part of the target state/);
183 183
});
184 +
185 +
test("identity reports how commits are signed", async () => {
186 +
  const dir = mkdtempSync(join(tmpdir(), "oxy-vscode-id-"));
187 +
  const home = join(dir, "home");
188 +
  mkdirSync(home);
189 +
  process.env.OXY_HOME = home;
190 +
  await oxy.check(dir, ["init", "repo"]);
191 +
  const root = join(dir, "repo");
192 +
  assert.equal(await oxy.identity(root), null, "none yet");
193 +
  await oxy.check(root, ["identity", "create", "--name", "Test", "--email", "[email protected]"]);
194 +
  const id = await oxy.identity(root);
195 +
  assert.equal(id?.who, "Test <[email protected]>");
196 +
  assert.equal(id?.secret, true);
197 +
  assert.ok(id?.mode === "change" || id?.mode === "seal");
198 +
});