OXYGENHUB

Withdraw tags, verify the transparency log, shallow clones, keep a side or both sides of a conflict, commit and amend by paths, merge remote bookmarks (closes #11) dac6876e4d08

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 0963ef6de8ef
When
· 2026-10-29 18:27 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
dac6876e4d08e3c81d98ed54cd9a4d39b7e92b9b64278c7b2554ae6bbea66992
Depends on
013fefef11e3 196392053c9f 1f7fc1e3ae4a 25103dddd993 3006b700c514 35d56b5803d9 44bfd5adfc9a 9020cf612749 ab81294a9ba9 b4626290e660 b55e0d00545e bed495a96408 c8f955891224 ca2304a7851a cdcffe39dd9f d2c9a5843004 f6bda77ecb9f
Depended on by
nothing yet
In bookmarks
main
In tags
v0.18.0
Edits
80 atoms, 4 tree operations, 12 files

Browse the tree at this change

added src/settle.ts +82 −0
1 +
// Settling conflicts with oxy's one-command answers (spec §5.7): keep one
2 +
// side of a binary file's or subrepo pin's replace conflict (oxygen#140),
3 +
// and keep every side of two changes adding lines at the same place
4 +
// (oxygen#121). The choices and the words live here, apart from VS Code,
5 +
// so they are tested.
6 +
7 +
import { ConflictInfo, KeptBoth } from "./oxy";
8 +
import { candidateBase } from "./markers";
9 +
10 +
/** One way to settle a conflict, as the quick pick offers it. */
11 +
export interface SettleChoice {
12 +
  label: string;
13 +
  description?: string;
14 +
  detail?: string;
15 +
  /** keep-side: write that side's version and settle; keep-current: settle with the working copy; open-subrepo: move the subrepo first. */
16 +
  action: "keep-side" | "keep-current" | "open-subrepo";
17 +
  /** The side's ChangeId, for keep-side. */
18 +
  change?: string;
19 +
}
20 +
21 +
/** Whether a conflict is a replace conflict: a binary file or subrepo pin changed two ways, with no markers to edit. */
22 +
export function isReplaceConflict(c: ConflictInfo): boolean {
23 +
  return c.kind === "replace" && !!c.path;
24 +
}
25 +
26 +
/**
27 +
 * The choices for a replace conflict. A file offers each side (the version
28 +
 * the side's change made); a subrepo pin offers the pin the nested
29 +
 * repository holds now, since oxy settles a pin with what the working copy
30 +
 * holds there, or opening it to move it first. `subjects` maps a side's
31 +
 * ChangeId to its subject line, where known.
32 +
 */
33 +
export function replaceChoices(c: ConflictInfo, subrepo: boolean, subjects: Map<string, string> = new Map()): SettleChoice[] {
34 +
  if (subrepo) {
35 +
    return [
36 +
      { label: "$(check) Keep the pin the subrepo holds now", description: c.path, detail: "Settles the pin conflict with the nested repository's current state (oxy resolve)", action: "keep-current" },
37 +
      { label: "$(folder-opened) Open the subrepo to move it first", detail: "Check out the side you want inside it, then come back and keep that pin", action: "open-subrepo" },
38 +
    ];
39 +
  }
40 +
  const sides: SettleChoice[] = (c.changes ?? []).map((id, i) => ({
41 +
    label: `$(check) Keep side ${i + 1}`,
42 +
    description: `${id.slice(0, 8)}${subjects.get(id) ? ` "${subjects.get(id)}"` : ""}`,
43 +
    detail: `The version change ${id.slice(0, 8)} made replaces every other`,
44 +
    action: "keep-side",
45 +
    change: id,
46 +
  }));
47 +
  return [
48 +
    ...sides,
49 +
    { label: "$(edit) Keep what the working copy holds now", description: c.path, detail: "Settles with the file as it is on disk; its .oxy-conflict-N candidates are removed (oxy resolve)", action: "keep-current" },
50 +
  ];
51 +
}
52 +
53 +
/** The `.oxy-conflict-N` candidates of `path` among a directory's entries (names, not paths). */
54 +
export function candidatesOf(name: string, entries: string[]): string[] {
55 +
  return entries.filter((e) => candidateBase(e) === name);
56 +
}
57 +
58 +
/** The modal question before withdrawing a tag. */
59 +
export function withdrawQuestion(tag: string): { message: string; detail: string } {
60 +
  return {
61 +
    message: `Withdraw tag ${tag}?`,
62 +
    detail:
63 +
      "The withdrawal is signed with your key and kept beside the tag's creation, and oxy push sends it to remotes that have the tag. It is logged, not erased, and the name can never be used again.",
64 +
  };
65 +
}
66 +
67 +
/** One line saying what `oxy resolve --both` did. */
68 +
export function keptBothSummary(r: KeptBoth): { message: string; settled: boolean; warn: boolean } {
69 +
  const kept = r.kept.reduce((n, k) => n + k.conflicts.length, 0);
70 +
  const files = new Set(r.kept.map((k) => k.path)).size;
71 +
  const parts: string[] = [];
72 +
  if (kept > 0) {
73 +
    parts.push(`kept every side of ${kept} conflict${kept === 1 ? "" : "s"} in ${files} file${files === 1 ? "" : "s"}`);
74 +
  }
75 +
  for (const l of r.left) {
76 +
    parts.push(`${l.path} conflict ${l.conflict} keeps its markers: ${l.why}`);
77 +
  }
78 +
  if (parts.length === 0) {
79 +
    return { message: "no conflict where two changes only add lines at the same place", settled: false, warn: true };
80 +
  }
81 +
  return { message: parts.join("; "), settled: kept > 0, warn: r.left.length > 0 };
82 +
}
added src/tlog.ts +44 −0
1 +
// The transparency log's lines in the Trust view (spec §5.12): what
2 +
// `oxy tlog verify --json` proved, who cosigned it, or why it failed. No
3 +
// vscode imports, so it is tested with plain Node.
4 +
5 +
import { TlogInfo } from "./oxy";
6 +
7 +
/** The last check of a repository's hub log: its result or its error. */
8 +
export type TlogCheck = { info: TlogInfo; when_ms: number } | { error: string; when_ms: number };
9 +
10 +
export interface TlogLine {
11 +
  label: string;
12 +
  description?: string;
13 +
  tooltip?: string;
14 +
  icon: string;
15 +
}
16 +
17 +
/** The Trust view's lines for a log check; `ago` words a time (milliseconds since the epoch). */
18 +
export function tlogLines(check: TlogCheck | undefined, ago: (ms: number) => string): TlogLine[] {
19 +
  if (!check) {
20 +
    return [{ label: "Transparency log not checked", description: "click to verify the hub's log", icon: "shield", tooltip: "Runs oxy tlog verify: checks that the hub shows everyone the same history." }];
21 +
  }
22 +
  if ("error" in check) {
23 +
    const first = check.error.trim().split("\n")[0] || "oxy tlog verify failed";
24 +
    return [{ label: "Transparency log NOT verified", description: first, icon: "error", tooltip: check.error.trim() }];
25 +
  }
26 +
  const { info } = check;
27 +
  const mine = info.entries.length;
28 +
  const lines: TlogLine[] = [
29 +
    {
30 +
      label: `Transparency log verified up to checkpoint ${info.size}`,
31 +
      description: `${info.origin} · ${mine} entr${mine === 1 ? "y" : "ies"} for this repository · ${ago(check.when_ms)}`,
32 +
      icon: "verified",
33 +
      tooltip: `${info.hub}\nRoot ${info.root}\nEvery entry checks against the root its log key signed, and the log extends what you saw before.`,
34 +
    },
35 +
  ];
36 +
  if (info.witnesses.length === 0) {
37 +
    lines.push({ label: "Cosigned by no witness you trust", icon: "warning", tooltip: "Without a witness, only your own past checks show the hub has not forked its log." });
38 +
  } else {
39 +
    for (const w of info.witnesses) {
40 +
      lines.push({ label: `Cosigned by ${w.name}`, description: ago(w.time_ms), icon: "key" });
41 +
    }
42 +
  }
43 +
  return lines;
44 +
}
added test/settle.test.mjs +56 −0
1 +
// Settling conflicts with oxy's one-command answers: the choices offered for
2 +
// a binary file's or subrepo pin's conflict, the candidates removed, and the
3 +
// words for a withdrawal and for oxy resolve --both.
4 +
5 +
import { test } from "node:test";
6 +
import assert from "node:assert/strict";
7 +
import { candidatesOf, isReplaceConflict, keptBothSummary, replaceChoices, withdrawQuestion } from "../out/settle.mjs";
8 +
9 +
const replace = { kind: "replace", path: "img/logo.png", changes: ["4189430d989317c02de6b3c12f1c433f", "9c0c8ef6b612ef990bc72f812ff732cc"], resolved: false };
10 +
11 +
test("a binary file's conflict offers each side, then the working copy's version", () => {
12 +
  assert.equal(isReplaceConflict(replace), true);
13 +
  assert.equal(isReplaceConflict({ kind: "text", path: "a.txt" }), false);
14 +
  const choices = replaceChoices(replace, false, new Map([["9c0c8ef6b612ef990bc72f812ff732cc", "New logo"]]));
15 +
  assert.deepEqual(
16 +
    choices.map((c) => [c.action, c.change, c.label, c.description]),
17 +
    [
18 +
      ["keep-side", "4189430d989317c02de6b3c12f1c433f", "$(check) Keep side 1", "4189430d"],
19 +
      ["keep-side", "9c0c8ef6b612ef990bc72f812ff732cc", "$(check) Keep side 2", '9c0c8ef6 "New logo"'],
20 +
      ["keep-current", undefined, "$(edit) Keep what the working copy holds now", "img/logo.png"],
21 +
    ],
22 +
  );
23 +
});
24 +
25 +
test("a subrepo pin's conflict keeps the pin the subrepo holds, or opens it to move it", () => {
26 +
  const choices = replaceChoices({ ...replace, path: "libs/lib" }, true);
27 +
  assert.deepEqual(choices.map((c) => c.action), ["keep-current", "open-subrepo"]);
28 +
  assert.match(choices[0].label, /Keep the pin the subrepo holds now/);
29 +
});
30 +
31 +
test("only the file's own candidates are removed", () => {
32 +
  assert.deepEqual(candidatesOf("logo.png", ["logo.png", "logo.png.oxy-conflict-2", "logo.png.oxy-conflict-3", "other.png.oxy-conflict-2", "logo.png.bak"]), ["logo.png.oxy-conflict-2", "logo.png.oxy-conflict-3"]);
33 +
  assert.deepEqual(candidatesOf("a", []), []);
34 +
});
35 +
36 +
test("withdrawing a tag says it is signed, kept and logged", () => {
37 +
  const q = withdrawQuestion("v1.2");
38 +
  assert.equal(q.message, "Withdraw tag v1.2?");
39 +
  assert.match(q.detail, /signed/);
40 +
  assert.match(q.detail, /kept/);
41 +
  assert.match(q.detail, /logged/);
42 +
  assert.match(q.detail, /never be used again/);
43 +
});
44 +
45 +
test("oxy resolve --both's result in one line", () => {
46 +
  assert.deepEqual(keptBothSummary({ kept: [{ path: "a.txt", conflicts: [1, 2] }, { path: "b.txt", conflicts: [1] }], left: [] }), {
47 +
    message: "kept every side of 3 conflicts in 2 files",
48 +
    settled: true,
49 +
    warn: false,
50 +
  });
51 +
  const mixed = keptBothSummary({ kept: [{ path: "a.txt", conflicts: [1] }], left: [{ path: "a.txt", conflict: 2, why: "both sides changed these lines; edit the markers" }] });
52 +
  assert.equal(mixed.settled, true);
53 +
  assert.equal(mixed.warn, true);
54 +
  assert.match(mixed.message, /a\.txt conflict 2 keeps its markers: both sides changed these lines/);
55 +
  assert.deepEqual(keptBothSummary({ kept: [], left: [] }).settled, false);
56 +
});
added test/tlog.test.mjs +45 −0
1 +
// The Trust view's transparency log lines, from `oxy tlog verify --json`.
2 +
3 +
import { test } from "node:test";
4 +
import assert from "node:assert/strict";
5 +
import { tlogLines } from "../out/tlog.mjs";
6 +
7 +
const ago = (ms) => `at ${ms}`;
8 +
const info = {
9 +
  hub: "https://oxygenhub.io",
10 +
  origin: "oxygenhub.io/log",
11 +
  size: 15553,
12 +
  root: "6f8b13148d40652127b016ec332166f45cd47c5aa2b51fe5fa8fb12f54d978a5",
13 +
  witnesses: [
14 +
    { name: "plex.witness", time_ms: 1793297007000 },
15 +
    { name: "narvi.witness", time_ms: 1793297007000 },
16 +
  ],
17 +
  entries: [{ index: 19, kind: "ref", repo: "3ad24d80c177396edd83fe470e431ec6", object: "ff04", signers: ["5506"], received_ms: 1792280811530 }],
18 +
};
19 +
20 +
test("not checked yet: one line offering the check", () => {
21 +
  const [line, ...rest] = tlogLines(undefined, ago);
22 +
  assert.equal(rest.length, 0);
23 +
  assert.equal(line.label, "Transparency log not checked");
24 +
  assert.equal(line.icon, "shield");
25 +
});
26 +
27 +
test("verified: the checkpoint, this repository's entries, and each witness", () => {
28 +
  const lines = tlogLines({ info, when_ms: 5 }, ago);
29 +
  assert.deepEqual(
30 +
    lines.map((l) => [l.label, l.description, l.icon]),
31 +
    [
32 +
      ["Transparency log verified up to checkpoint 15553", "oxygenhub.io/log · 1 entry for this repository · at 5", "verified"],
33 +
      ["Cosigned by plex.witness", "at 1793297007000", "key"],
34 +
      ["Cosigned by narvi.witness", "at 1793297007000", "key"],
35 +
    ],
36 +
  );
37 +
});
38 +
39 +
test("no witness is a warning, and a failure shows oxy's error", () => {
40 +
  const lines = tlogLines({ info: { ...info, witnesses: [], entries: [] }, when_ms: 5 }, ago);
41 +
  assert.match(lines[0].description, /0 entries/);
42 +
  assert.deepEqual([lines[1].label, lines[1].icon], ["Cosigned by no witness you trust", "warning"]);
43 +
  const [failed] = tlogLines({ error: "the checkpoint does not extend the one seen before\nmore", when_ms: 5 }, ago);
44 +
  assert.deepEqual([failed.label, failed.description, failed.icon], ["Transparency log NOT verified", "the checkpoint does not extend the one seen before", "error"]);
45 +
});
modified README.md +9 −1
…
34 unchanged lines
35 35
36 36
- **Only some files:** right-click them and choose **Commit Selected**.
37 37
- **Only some hunks:** use **Commit Hunks…** from the view's menu. Anything a chosen hunk needs comes along with it.
38 +
- **Fix up the last change with only some files:** right-click them and choose **Amend Selected Paths**.
38 39
- **Fix up the last change:** click the pencil in the view's title bar for **amend mode**. `Ctrl+Enter` then amends, and the box starts from the last change's message. Click the cross to leave amend mode.
39 40
- **Reuse a message:** **Previous Commit Messages…** puts a recent one back in the box.
40 41
…
6 unchanged lines
47 48
1. Conflicted files appear under **Conflicts**.
48 49
2. In the editor, CodeLens actions above each marker accept one side, all sides, or a deletion.
49 50
3. For a conflict in the tree, such as two files given the same name, **Keep This Candidate** on the candidate you want.
51 +
   - A binary file or subrepo pin changed two ways has no markers: **Keep a Side…** on it keeps one side's version.
52 +
   - Where two changes only added lines at the same place, **Keep Both Sides…** keeps every side, earliest change first. Oxygen leaves the markers where the sides changed the same lines and says so.
53 +
   - **Commit Resolution of Selected Paths** commits what you have resolved so far and leaves the rest for later.
50 54
4. Choose **Mark Resolved**, then commit. The commit box reads "Describe the resolution".
51 55
52 56
OxygenHub's conflict resolver also offers **Resolve in VS Code**. The link opens this extension, which switches your checkout to the conflict's bookmark (after asking), pulls it, and opens the file here.
…
7 unchanged lines
60 64
- The status bar's sync counter shows changes to pull and to push. Click it to **Sync**.
61 65
- **Fetch**, **Pull**, **Push** and **Push Bookmark** are in the view's `…` menu.
62 66
- Pushing asks first unless `oxygen.confirmSync` is off.
67 +
- **Merge Bookmark…** also lists remotes' bookmarks, such as `origin/develop`, as last fetched.
68 +
- **Clone Repository…** offers a **Shallow** clone (latest state only) for big repositories.
63 69
64 70
### Read and rewrite history
65 71
…
27 unchanged lines
93 99
94 100
### Check trust
95 101
96 -
The **Oxygen Trust** view shows the repository's trust policy and each change's signature. **Verify Signatures** checks them all.
102 +
The **Oxygen Trust** view shows the repository's trust policy and each change's signature. **Verify Signatures** checks them all. **Verify Transparency Log** checks that your hub shows everyone the same history. The view then shows the checkpoint it verified and the witnesses who cosigned it.
97 103
104 +
**Withdraw Tag…**, also in **Tags…**, withdraws a tag. The withdrawal is signed and kept, and push sends it on. The tag's name can't be used again.
105 +
98 106
## Colours and getting started
99 107
100 108
The history graph and the file letters use Oxygen's own themeable colours: `oxygen.lane1`…`oxygen.lane8` for the graph's lanes, plus `oxygen.head`, `oxygen.workingCopy`, `oxygen.signed`, `oxygen.sealed`, `oxygen.invalid` and `oxygen.added`/`modified`/`removed`/`renamed`/`conflict`/`resolved`. Set them under `workbench.colorCustomizations`. **Get started with Oxygen**, on the Welcome page, walks through a first repository. **Oxygen: Create Identity…** makes your signing key.
…
35 unchanged lines
modified docs/spec.md +29 −7
…
22 unchanged lines
23 23
| Commit box, `Ctrl+Enter` | Same | `oxy commit -m`; amend from the menu |
24 24
| Quick diff gutter | Same | Base content via `oxy cat` (§4) |
25 25
| File decorations (M, A, D, U, C) | M, A, D, R (renamed), X (mode), ‼ (conflict markers), ✔ (resolved) | From `oxy status --json` |
26 -
| Branch picker in the status bar | **Bookmark** picker: switch, create, merge | `oxy bookmark` |
26 +
| Branch picker in the status bar | **Bookmark** picker: switch, create, merge (a remote's bookmark too) | `oxy bookmark`, `oxy merge REMOTE/BOOKMARK` |
27 27
| Sync (fetch, pull, push) | Same, plus per-bookmark push | Counts from `oxy bookmark list --json` |
28 28
| Timeline view (file history) | Timeline: changes that touched the file, from `oxy log --json --path` | Exact, follows renames |
29 -
| Merge conflict editor (Accept current / incoming / both) | **Conflict editor** over `oxy` markers with the same CodeLens actions, plus "Mark resolved" that runs `oxy resolve` | Markers name the contributing changes |
29 +
| Merge conflict editor (Accept current / incoming / both) | **Conflict editor** over `oxy` markers with the same CodeLens actions, plus "Mark resolved" that runs `oxy resolve`, "Keep a Side" for binary files and subrepo pins, and "Keep Both Sides" for lines added at the same place | Markers name the contributing changes |
30 30
| Stash | Workspaces (`oxy workspace add`) | Stash has no equivalent; a second workspace is the honest answer |
31 31
| Git graph (via extensions) | **History view**: heads and dependencies as a graph | `oxy log --json` carries dependencies |
32 32
| Blame (via GitLens) | Inline blame toggle and a Blame view | `oxy blame --json` |
33 33
| Undo last commit | **Operation log** view: any operation undone with one click | `oxy op log --json`, `oxy undo` |
34 34
| Submodules | Subrepos node in the SCM view with update and status | `oxy submodule status --json` |
35 35
| — | Bisect panel | `oxy bisect` |
36 -
| — | Trust panel: signature status per change, policy summary | `oxy verify --json`, `oxy policy show --json` |
36 +
| — | Trust panel: signature status per change, policy summary, the hub's transparency log | `oxy verify --json`, `oxy policy show --json`, `oxy tlog verify --json` |
37 37
| Git output channel | Oxygen output channel with every command run | |
38 38
39 39
## 3. Architecture
…
30 unchanged lines
70 70
| `oxy remote list --json`, `fetch --json`, `pull --json`, `push --json` | Names, urls, results |
71 71
| `oxy prompt` | Exists; used for the status bar |
72 72
| `oxy discard PATH..` | Restore paths to the working copy's base (new; the extension's "Discard changes") |
73 +
| `oxy commit -m M PATH..`, `oxy amend PATH..` | Commit or amend only these paths, a conflict's resolution included; the rest stays uncommitted (oxy 0.5.0) |
74 +
| `oxy bookmark list --all --json` | Also remotes' bookmarks with no local one, named `REMOTE/NAME`; `oxy merge REMOTE/NAME` merges one as last fetched (oxygen#117) |
75 +
| `oxy resolve PATH..` | Settle a binary file's or subrepo pin's replace conflict with what the working copy holds there (oxygen#140) |
76 +
| `oxy resolve --both [--reverse] [PATH..]` | Keep every side of each adjacent insertion, earliest change first; reports what keeps its markers and why (oxygen#121, oxy 0.8.0) |
77 +
| `oxy tag delete NAME -r REASON` | Withdraw a tag: a signed withdrawal kept beside its creation; the name is never reused |
78 +
| `oxy tlog verify --json [HUB]` | The hub's transparency log checked: checkpoint size, root, witnesses' cosignatures, this repository's entries |
79 +
| `oxy clone --depth 0 URL DIR` | A shallow clone from the remote's checkpoint: the latest state only |
73 80
| `oxy --version --json` | Version and protocol version |
74 81
75 82
Every JSON command MUST print one JSON document on stdout and nothing else, use exit status 0 for success, and put human-readable errors on stderr with a non-zero status. Ids are hex strings. Times are milliseconds since the epoch.
…
4 unchanged lines
80 87
81 88
- Groups: **Conflicts** (only when present, listed first), **Changes**. Each entry shows the path, a status letter, and a tooltip with the status word; clicking opens the diff editor (working file against `oxy:` base content; for an added file the file itself; for a removed file the base content).
82 89
- Title actions: Commit (with message box), Refresh, Fetch, Pull, Push, More… (Amend, Undo last operation, Open history, Open operation log).
83 -
- Entry actions: Open file, Open changes, Discard changes (confirm), Commit only this path.
84 -
- Multi-select commit: selecting entries and choosing "Commit selected" runs `oxy commit PATH..`.
90 +
- Entry actions: Open file, Open changes, Discard changes (confirm), Commit only this path, Amend selected paths.
91 +
- Multi-select commit: selecting entries and choosing "Commit selected" runs `oxy commit PATH..`; "Amend Selected Paths" runs `oxy amend PATH..`, folding only those into the head the working copy sits on.
85 92
- The view's badge is the number of changed paths; the badge turns red with the conflict count while conflicts exist.
86 93
87 94
### 5.2 Diff editor and quick diff
…
4 unchanged lines
92 99
93 100
### 5.3 Commit
94 101
95 -
- The message box supports multi-line messages; `Ctrl+Enter` commits everything, the dropdown offers **Commit selected paths**, **Commit hunks…** (opens a quick pick of hunks from `oxy hunks --json`), **Amend**, **Amend into…** (quick pick of changes), and **Commit and push**.
102 +
- The message box supports multi-line messages; `Ctrl+Enter` commits everything, the dropdown offers **Commit selected paths**, **Commit hunks…** (opens a quick pick of hunks from `oxy hunks --json`), **Amend**, **Amend selected paths** (`oxy amend PATH..`), **Amend into…** (quick pick of changes), and **Commit and push**.
96 103
- Empty message: the extension opens an editor for the message, like Git's.
97 104
- A pre-commit hook veto shows its stderr in a notification with "Show output".
98 105
…
6 unchanged lines
105 112
### 5.5 Bookmarks
106 113
107 114
- Picker: current bookmark marked, each with heads count and ahead/behind, plus **Create bookmark…**, **Merge bookmark into current…**, **Delete bookmark…**. Switching with uncommitted changes offers Discard or Cancel (never silently).
115 +
- **Merge bookmark…** lists the other local bookmarks, then remotes' bookmarks that have no local one (`oxy bookmark list --all --json`, shown as `origin/develop`, "as last fetched"); picking one runs `oxy merge` with that name.
116 +
- **Tags…** lists tags to check out, plus **Create tag…** and **Withdraw a tag…**. **Withdraw Tag…** asks for the tag and a reason, confirms in a modal that the withdrawal is signed with your key, kept beside the tag's creation and logged (pushed to remotes that have the tag), and that the name is never used again, then runs `oxy tag delete NAME -r REASON`.
108 117
- Explorer context menu on a folder: "Open in new workspace" runs `oxy workspace add`.
109 118
110 119
### 5.6 History view
…
6 unchanged lines
117 126
118 127
- Files with markers open in the editor with CodeLens over each region: **Accept side 1**, **Accept side 2**, **Accept both**, **Compare sides**. Side labels are the contributing changes' short ids and subjects, as the markers carry them.
119 128
- Tree conflicts (`.oxy-conflict-N` candidates) appear under Conflicts with actions "Keep this candidate" (renames it into place and removes the rest).
129 +
- A binary file's or subrepo pin's conflict (a replace conflict, no markers to edit) shows **Keep a Side…**. For a file it offers each side, labelled with the side's change, plus the version on disk: keeping a side writes that change's version of the file (`oxy cat PATH -r CHANGE`), removes the `.oxy-conflict-N` candidates, and runs `oxy resolve PATH`, which settles the conflict with what the working copy holds. For a subrepo pin it offers the pin the nested repository holds now, or opening the subrepo to move it first (oxygen#140).
130 +
- A text conflict, and the Conflicts group, offer **Keep Both Sides (Lines Added at the Same Place)…**: `oxy resolve --both [PATH..]`, earliest change first or (`--reverse`) latest first. Where the sides changed the same lines the markers stay, and oxy's reason is shown (oxygen#121; needs oxy 0.8.0).
120 131
- "Mark resolved" runs `oxy resolve --json` and reports what remains; the commit action reads "Commit resolution" while conflicts are resolved but not committed.
132 +
- **Commit Resolution of Selected Paths**, on entries of the Conflicts group, commits only those paths' resolution (`oxy commit -m M PATH..`) while other conflicts stay for later; it asks first if a chosen path is not resolved yet.
121 133
122 134
### 5.8 Blame
123 135
…
14 unchanged lines
138 150
### 5.12 Trust
139 151
140 152
- Each change in the history view shows a signature badge (signed, sealed, unsigned, invalid, revoked key). A **Trust** section in the SCM view summarises the policy (roots, rules, expiry) and flags an expired policy or an unverified remote.
153 +
- The Trust view's last line is the hub's **transparency log**. It is checked on request, not on refresh, since it asks the hub: **Verify Transparency Log** (the view's title button, the line itself, or the command) runs `oxy tlog verify --json` against the checkout's remote and shows "verified up to checkpoint N" with the log's origin and this repository's entry count, then one line per witness that cosigned the checkpoint (or a warning that none you trust did), or the error when the check fails.
141 154
142 155
### 5.13 Remotes and sync
143 156
144 157
- Remote picker for fetch/pull/push when more than one remote exists; results shown as notifications (rejected pushes explain why and offer Pull). Tokens are read from the repository configuration; the extension never stores secrets itself.
158 +
- **Clone Repository…** asks for the URL and the parent folder, then offers **Full history** or **Shallow** (latest state only, fast for big repositories), which runs `oxy clone --depth 0 URL DIR`.
145 159
146 160
### 5.14 Settings
147 161
…
9 unchanged lines
157 171
158 172
### 5.15 Commands (all prefixed `Oxygen:`)
159 173
160 -
Commit, Commit Selected Paths, Commit Hunks…, Amend, Amend Into…, Discard Changes, Refresh, Fetch, Pull, Push, Push Bookmark…, Sync, Switch Bookmark…, Create Bookmark…, Merge Bookmark…, Delete Bookmark…, Open History, Open Operation Log, Undo Last Operation, Toggle Inline Blame, Show Blame, Resolve Conflicts, Mark Resolved, Checkout Change…, Describe Change…, Abandon Change…, Squash Into…, Rebase Onto…, Create Tag…, Add Workspace…, Update Subrepos, Bisect Start/Good/Bad/Skip/Run/Reset, Verify Signatures, Show Policy, Open Output, Clone Repository…, Initialize Repository.
174 +
Commit, Commit Selected Paths, Commit Resolution of Selected Paths, Commit Hunks…, Amend, Amend Selected Paths, Amend Into…, Discard Changes, Refresh, Fetch, Pull, Push, Push Bookmark…, Sync, Switch Bookmark…, Create Bookmark…, Merge Bookmark…, Delete Bookmark…, Open History, Open Operation Log, Undo Last Operation, Toggle Inline Blame, Show Blame, Resolve Conflicts, Mark Resolved, Keep a Side…, Keep Both Sides (Lines Added at the Same Place)…, Checkout Change…, Describe Change…, Abandon Change…, Squash Into…, Rebase Onto…, Tags…, Create Tag…, Withdraw Tag…, Add Workspace…, Update Subrepos, Bisect Start/Good/Bad/Skip/Run/Reset, Verify Signatures, Verify Transparency Log, Show Policy, Open Output, Clone Repository…, Initialize Repository.
161 175
162 176
Keybindings match the Git extension where a counterpart exists (for example `Ctrl+Enter` in the commit box).
163 177
…
42 unchanged lines
206 220
- `oxy` gained `tag list --json` and `workspace list --json` for §4, and two bugs the extension's tests found were fixed in `oxy`: a chosen addition hunk now carries its content, and an unsigned repository keeps a tag's message.
207 221
- Extensions are per VS Code profile; the install command must name the profile a workspace uses.
208 222
223 +
## 8b. Implementation notes (2026-10-29): oxy 0.5 to 0.9
224 +
225 +
- Brought up to oxy 0.9 (oxygen-vscode#11): tag withdrawal, the transparency log in the Trust view, shallow clone, keep-a-side and keep-both-sides settling, commit and amend by paths, and merging a remote's bookmark, each through the commands in §4.
226 +
- The CLI names a withdrawal `oxy tag delete NAME -r REASON` (help: "Withdraw a tag"); there is no `tag withdraw` subcommand.
227 +
- `oxy resolve PATH` settles a replace conflict with whatever the working copy holds; there is no per-side flag, so the extension writes the chosen side's version first. `oxy status --json` does not say which text conflicts are adjacent insertions (only the human output does), so **Keep Both Sides** is offered on every text conflict and reports oxy's refusals.
228 +
- `oxy resolve --both` needs oxy 0.8.0; the tests that use it are skipped on older oxy.
229 +
- `bookmark list --all --json` marks no field for a remote's bookmark; the extension recognises one by its `REMOTE/` name prefix, `remote` field and null ahead/behind counts.
230 +
209 231
## 9a. Exported API (0.7.0)
210 232
211 233
`activate` returns an `OxygenAPI` (`src/api.ts`): the repositories as handles (root, status, bookmarks, current bookmark, `remotes()`, `json(args)`, `refresh()`), `onDidChangeStatus`, `onDidChangeRepositories`, `getRepository(path)`, and `registerRemoteProvider`. A remote provider gives an id and label, `matches(remoteUrl)` and `urlFor(remoteUrl, target)` for repository, bookmark, change, file (with line), proposals and issues targets. This extension uses the providers for its **Open on Remote** commands and sets the `oxygen.hasRemoteProvider` context; oxygenhub-vscode registers one for hubs and consumes the handles instead of running `oxy` itself, mirroring how the GitHub Pull Requests extension builds on the built-in Git extension.
…
6 unchanged lines
modified package.json +97 −4
…
81 unchanged lines
82 82
        "category": "Oxygen"
83 83
      },
84 84
      {
85 +
        "command": "oxygen.commitResolution",
86 +
        "title": "Commit Resolution of Selected Paths",
87 +
        "category": "Oxygen",
88 +
        "icon": "$(check)"
89 +
      },
90 +
      {
85 91
        "command": "oxygen.commitHunks",
86 92
        "title": "Commit Hunks\u2026",
87 93
        "category": "Oxygen"
…
2 unchanged lines
90 96
        "command": "oxygen.amend",
91 97
        "title": "Amend",
92 98
        "category": "Oxygen"
99 +
      },
100 +
      {
101 +
        "command": "oxygen.amendSelected",
102 +
        "title": "Amend Selected Paths",
103 +
        "category": "Oxygen",
104 +
        "icon": "$(edit)"
93 105
      },
94 106
      {
95 107
        "command": "oxygen.undo",
…
94 unchanged lines
190 202
        "icon": "$(check)"
191 203
      },
192 204
      {
205 +
        "command": "oxygen.keepSide",
206 +
        "title": "Keep a Side\u2026",
207 +
        "category": "Oxygen",
208 +
        "icon": "$(check)"
209 +
      },
210 +
      {
211 +
        "command": "oxygen.keepBothSides",
212 +
        "title": "Keep Both Sides (Lines Added at the Same Place)\u2026",
213 +
        "category": "Oxygen",
214 +
        "icon": "$(list-flat)"
215 +
      },
216 +
      {
193 217
        "command": "oxygen.markResolved",
194 218
        "title": "Mark Resolved",
195 219
        "category": "Oxygen",
…
68 unchanged lines
264 288
        "category": "Oxygen"
265 289
      },
266 290
      {
291 +
        "command": "oxygen.verifyTlog",
292 +
        "title": "Verify Transparency Log",
293 +
        "category": "Oxygen",
294 +
        "icon": "$(verified)"
295 +
      },
296 +
      {
267 297
        "command": "oxygen.tags",
268 298
        "title": "Tags\u2026",
269 299
        "category": "Oxygen"
…
2 unchanged lines
272 302
        "command": "oxygen.createTag",
273 303
        "title": "Create Tag\u2026",
274 304
        "category": "Oxygen"
305 +
      },
306 +
      {
307 +
        "command": "oxygen.withdrawTag",
308 +
        "title": "Withdraw Tag\u2026",
309 +
        "category": "Oxygen",
310 +
        "icon": "$(circle-slash)"
275 311
      },
276 312
      {
277 313
        "command": "oxygen.addWorkspace",
…
170 unchanged lines
448 484
          "when": "scmProvider == oxygen"
449 485
        },
450 486
        {
451 -
          "command": "oxygen.addWorkspace",
487 +
          "command": "oxygen.withdrawTag",
452 488
          "group": "6_more@3",
453 489
          "when": "scmProvider == oxygen"
454 490
        },
455 491
        {
456 -
          "command": "oxygen.updateSubrepos",
492 +
          "command": "oxygen.addWorkspace",
457 493
          "group": "6_more@4",
458 494
          "when": "scmProvider == oxygen"
459 495
        },
460 496
        {
461 -
          "command": "oxygen.verify",
497 +
          "command": "oxygen.updateSubrepos",
462 498
          "group": "6_more@5",
463 499
          "when": "scmProvider == oxygen"
464 500
        },
465 501
        {
502 +
          "command": "oxygen.verify",
503 +
          "group": "6_more@6",
504 +
          "when": "scmProvider == oxygen"
505 +
        },
506 +
        {
507 +
          "command": "oxygen.verifyTlog",
508 +
          "group": "6_more@7",
509 +
          "when": "scmProvider == oxygen"
510 +
        },
511 +
        {
466 512
          "command": "oxygen.commitHunks",
467 513
          "group": "2_commit@1",
468 514
          "when": "scmProvider == oxygen"
…
115 unchanged lines
584 630
          "command": "oxygen.commitSelected",
585 631
          "group": "1_modification@1",
586 632
          "when": "scmProvider == oxygen"
633 +
        },
634 +
        {
635 +
          "command": "oxygen.amendSelected",
636 +
          "group": "1_modification@3",
637 +
          "when": "scmProvider == oxygen && scmResourceGroup == changes"
587 638
        },
588 639
        {
589 640
          "command": "oxygen.discard",
…
14 unchanged lines
604 655
          "command": "oxygen.keepCandidate",
605 656
          "group": "0_conflict",
606 657
          "when": "scmProvider == oxygen && scmResourceState == candidate"
658 +
        },
659 +
        {
660 +
          "command": "oxygen.keepSide",
661 +
          "group": "inline@0",
662 +
          "when": "scmProvider == oxygen && scmResourceState == replace"
663 +
        },
664 +
        {
665 +
          "command": "oxygen.keepSide",
666 +
          "group": "0_conflict@1",
667 +
          "when": "scmProvider == oxygen && scmResourceState == replace"
668 +
        },
669 +
        {
670 +
          "command": "oxygen.keepBothSides",
671 +
          "group": "0_conflict@2",
672 +
          "when": "scmProvider == oxygen && scmResourceGroup == conflicts && scmResourceState == markers"
673 +
        },
674 +
        {
675 +
          "command": "oxygen.commitResolution",
676 +
          "group": "0_conflict@3",
677 +
          "when": "scmProvider == oxygen && scmResourceGroup == conflicts"
607 678
        }
608 679
      ],
609 680
      "scm/resourceGroup/context": [
610 681
        {
611 682
          "command": "oxygen.markResolved",
612 683
          "group": "inline",
684 +
          "when": "scmProvider == oxygen && scmResourceGroup == conflicts"
685 +
        },
686 +
        {
687 +
          "command": "oxygen.keepBothSides",
688 +
          "group": "1_conflict@1",
613 689
          "when": "scmProvider == oxygen && scmResourceGroup == conflicts"
614 690
        }
615 691
      ],
…
10 unchanged lines
626 702
        },
627 703
        {
628 704
          "command": "oxygen.refreshTrust",
705 +
          "group": "navigation",
706 +
          "when": "view == oxygen.trust"
707 +
        },
708 +
        {
709 +
          "command": "oxygen.verifyTlog",
629 710
          "group": "navigation",
630 711
          "when": "view == oxygen.trust"
631 712
        }
…
76 unchanged lines
708 789
        },
709 790
        {
710 791
          "command": "oxygen.history.rebase",
792 +
          "when": "false"
793 +
        },
794 +
        {
795 +
          "command": "oxygen.amendSelected",
796 +
          "when": "false"
797 +
        },
798 +
        {
799 +
          "command": "oxygen.commitResolution",
800 +
          "when": "false"
801 +
        },
802 +
        {
803 +
          "command": "oxygen.keepSide",
711 804
          "when": "false"
712 805
        }
713 806
      ],
…
486 unchanged lines
1200 1293
    "build": "esbuild src/extension.ts --bundle --platform=node --format=cjs --external:vscode --outfile=dist/extension.js --sourcemap",
1201 1294
    "watch": "npm run build -- --watch",
1202 1295
    "typecheck": "tsc --noEmit -p .",
1203 -
    "pretest": "esbuild src/oxy.ts --bundle --platform=node --format=esm --sourcemap --outfile=out/oxy.mjs && esbuild src/diffutil.ts --bundle --platform=node --format=esm --sourcemap --outfile=out/diffutil.mjs && esbuild src/markers.ts --bundle --platform=node --format=esm --sourcemap --outfile=out/markers.mjs && esbuild src/api.ts --bundle --platform=node --format=esm --sourcemap --outfile=out/api.mjs && esbuild src/commitbox.ts --bundle --platform=node --format=esm --sourcemap --outfile=out/commitbox.mjs && esbuild src/handoff.ts --bundle --platform=node --format=esm --sourcemap --outfile=out/handoff.mjs && esbuild src/paths.ts --bundle --platform=node --format=esm --sourcemap --outfile=out/paths.mjs && esbuild src/looks.ts --bundle --platform=node --format=esm --sourcemap --outfile=out/looks.mjs",
1296 +
    "pretest": "esbuild src/oxy.ts --bundle --platform=node --format=esm --sourcemap --outfile=out/oxy.mjs && esbuild src/diffutil.ts --bundle --platform=node --format=esm --sourcemap --outfile=out/diffutil.mjs && esbuild src/markers.ts --bundle --platform=node --format=esm --sourcemap --outfile=out/markers.mjs && esbuild src/api.ts --bundle --platform=node --format=esm --sourcemap --outfile=out/api.mjs && esbuild src/commitbox.ts --bundle --platform=node --format=esm --sourcemap --outfile=out/commitbox.mjs && esbuild src/handoff.ts --bundle --platform=node --format=esm --sourcemap --outfile=out/handoff.mjs && esbuild src/paths.ts --bundle --platform=node --format=esm --sourcemap --outfile=out/paths.mjs && esbuild src/looks.ts --bundle --platform=node --format=esm --sourcemap --outfile=out/looks.mjs && esbuild src/settle.ts --bundle --platform=node --format=esm --sourcemap --outfile=out/settle.mjs && esbuild src/tlog.ts --bundle --platform=node --format=esm --sourcemap --outfile=out/tlog.mjs",
1204 1297
    "test": "node --enable-source-maps --test \"test/**/*.test.mjs\"",
1205 1298
    "test:ci": "node --enable-source-maps --test --experimental-test-coverage --test-coverage-exclude=\"test/**\" --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=out/junit.xml --test-reporter=lcov --test-reporter-destination=out/lcov.info \"test/**/*.test.mjs\"",
1206 1299
    "check": "npm run typecheck && npm run build && npm test",
…
10 unchanged lines
modified src/extension.ts +256 −6
…
2 unchanged lines
3 3
4 4
import * as vscode from "vscode";
5 5
import { OxygenAPI, RemoteProvider, RemoteTarget, RepositoryHandle, linkedLines, pickProvider, repositoryFor as rootFor, stateSpec } from "./api";
6 -
import { basename, join } from "node:path";
7 -
import { Oxy, OxyError, ShowInfo, findBinary } from "./oxy";
6 +
import { basename, dirname, join } from "node:path";
7 +
import { Oxy, OxyError, ShowInfo, findBinary, mergeChoices } from "./oxy";
8 8
import { Repository, OxyResourceState, SubrepoState } from "./repository";
9 9
import { BisectProvider, TrustProvider } from "./panels";
10 10
import { OxyContentProvider, SCHEME, baseUri, oxyUri } from "./contentProvider";
…
2 unchanged lines
13 13
import { BlameController } from "./blame";
14 14
import { HistoryPanel } from "./history";
15 15
import { MessageHistory, conflictMarkerLines, placeholder, signingLabel } from "./commitbox";
16 -
import { readFile, stat } from "node:fs/promises";
16 +
import { readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
17 17
import { ConflictCodeLensProvider, acceptSides, keepCandidate, markResolved } from "./conflicts";
18 18
import { parsePlanQuery, parseResolveQuery, sameRemote } from "./handoff";
19 +
import { candidatesOf, keptBothSummary, replaceChoices, withdrawQuestion } from "./settle";
19 20
20 21
const MIN_VERSION = { format: 2, protocol: 1 };
21 22
…
64 unchanged lines
86 87
    vscode.commands.registerCommand("oxygen.commitSelected", (state?: OxyResourceState, states?: OxyResourceState[]) => commitSelected(states?.length ? states : state ? [state] : [])),
87 88
    vscode.commands.registerCommand("oxygen.commitHunks", (arg?: Repository | vscode.SourceControl) => commitHunks(arg)),
88 89
    vscode.commands.registerCommand("oxygen.amend", (arg?: Repository | vscode.SourceControl) => amend(arg)),
90 +
    vscode.commands.registerCommand("oxygen.amendSelected", (state?: OxyResourceState, states?: OxyResourceState[]) => amendSelected(states?.length ? states : state ? [state] : [])),
91 +
    vscode.commands.registerCommand("oxygen.commitResolution", (state?: OxyResourceState, states?: OxyResourceState[]) => commitResolution(states?.length ? states : state ? [state] : [])),
89 92
    vscode.commands.registerCommand("oxygen.history.split", (ctx?: { root?: string; hash?: string }) => historyContext(ctx, "split")),
90 93
    vscode.commands.registerCommand("oxygen.history.squash", (ctx?: { root?: string; hash?: string }) => historyContext(ctx, "squash")),
91 94
    vscode.commands.registerCommand("oxygen.history.rebase", (ctx?: { root?: string; hash?: string }) => historyContext(ctx, "rebase")),
…
23 unchanged lines
115 118
    vscode.commands.registerCommand("oxygen.showChange", (root?: string, hash?: string) => root && hash && showChange(root, hash)),
116 119
    vscode.commands.registerCommand("oxygen.acceptSides", (uri: vscode.Uri, index: number, keep: number[]) => acceptSides(uri, index, keep).catch(showError)),
117 120
    vscode.commands.registerCommand("oxygen.keepCandidate", (state?: OxyResourceState) => state && keepCandidate(state.repository, state.path).catch(showError)),
121 +
    vscode.commands.registerCommand("oxygen.keepSide", (state?: OxyResourceState) => state && keepSide(state)),
122 +
    vscode.commands.registerCommand("oxygen.keepBothSides", (arg?: OxyResourceState | Repository | vscode.SourceControlResourceGroup, states?: OxyResourceState[]) => {
123 +
      const picked = states?.length ? states : arg instanceof OxyResourceState ? [arg] : [];
124 +
      return keepBothSides(picked.length ? picked[0].repository : repositoryFor(arg instanceof Repository ? arg : undefined), picked.map((s) => s.path));
125 +
    }),
118 126
    vscode.commands.registerCommand("oxygen.markResolved", (arg?: Repository | vscode.SourceControl) => {
119 127
      const repo = repositoryFor(arg);
120 128
      return repo && markResolved(repo).catch(showError);
…
14 unchanged lines
135 143
    vscode.commands.registerCommand("oxygen.refreshBisect", () => bisectView.refresh()),
136 144
    vscode.commands.registerCommand("oxygen.refreshTrust", () => trustView.refresh()),
137 145
    vscode.commands.registerCommand("oxygen.verify", (arg?: Repository | vscode.SourceControl) => verify(arg)),
146 +
    vscode.commands.registerCommand("oxygen.verifyTlog", (arg?: Repository | vscode.SourceControl) => verifyTlog(arg)),
138 147
    vscode.commands.registerCommand("oxygen.tags", (arg?: Repository | vscode.SourceControl) => tags(arg)),
139 148
    vscode.commands.registerCommand("oxygen.createTag", (arg?: Repository | vscode.SourceControl) => createTag(arg)),
149 +
    vscode.commands.registerCommand("oxygen.withdrawTag", (arg?: Repository | vscode.SourceControl) => withdrawTag(arg)),
140 150
    vscode.commands.registerCommand("oxygen.addWorkspace", (arg?: Repository | vscode.SourceControl) => addWorkspace(arg)),
141 151
    vscode.commands.registerCommand("oxygen.clone", () => cloneRepository()),
142 152
    vscode.commands.registerCommand("oxygen.createIdentity", () => createIdentity()),
…
388 unchanged lines
531 541
    remember(repo, message);
532 542
    repo.sourceControl.inputBox.value = "";
533 543
    setAmend(repo, false);
544 +
    await repo.refresh();
545 +
  } catch (e) {
546 +
    showError(e);
547 +
  }
548 +
}
549 +
550 +
/** Fold only the chosen paths into the head the working copy sits on; the rest stays uncommitted (oxy amend PATH…). */
551 +
async function amendSelected(states: OxyResourceState[]): Promise<void> {
552 +
  if (states.length === 0) {
553 +
    return;
554 +
  }
555 +
  const repo = states[0].repository;
556 +
  const paths = states.map((s) => s.path);
557 +
  if (!(await markersOk(repo, paths))) {
558 +
    return;
559 +
  }
560 +
  const message = repo.sourceControl.inputBox.value.trim();
561 +
  try {
562 +
    await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Amending" }, () => repo.oxy.amend(repo.root, message || undefined, paths));
563 +
    if (message) {
564 +
      remember(repo, message);
565 +
      repo.sourceControl.inputBox.value = "";
566 +
    }
567 +
    await repo.refresh();
568 +
    vscode.window.setStatusBarMessage(`$(edit) Amended ${paths.length === 1 ? basename(paths[0]) : `${paths.length} paths`} into the last change`, 4000);
569 +
  } catch (e) {
570 +
    showError(e);
571 +
  }
572 +
}
573 +
574 +
/**
575 +
 * Commit the resolution of the chosen conflicted paths only (oxy commit
576 +
 * PATH… while other conflicts remain): their settled content becomes a
577 +
 * resolution change, and the rest stays conflicted for later.
578 +
 */
579 +
async function commitResolution(states: OxyResourceState[]): Promise<void> {
580 +
  if (states.length === 0) {
581 +
    return;
582 +
  }
583 +
  const repo = states[0].repository;
584 +
  const paths = states.map((s) => s.path);
585 +
  const unresolved = (repo.status?.conflicts ?? []).filter((c) => c.path && paths.includes(c.path) && c.resolved !== true);
586 +
  if (unresolved.length > 0) {
587 +
    const go = await vscode.window.showWarningMessage(
588 +
      `${unresolved.map((c) => c.path).join(", ")} ${unresolved.length === 1 ? "is" : "are"} not resolved yet. Commit anyway?`,
589 +
      { modal: true, detail: "Committing records the file as it is; any marker lines become ordinary text." },
590 +
      "Commit anyway",
591 +
    );
592 +
    if (go !== "Commit anyway") {
593 +
      return;
594 +
    }
595 +
  }
596 +
  const names = paths.length === 1 ? basename(paths[0]) : `${paths.length} paths`;
597 +
  const fromBox = repo.sourceControl.inputBox.value.trim();
598 +
  const message = fromBox || (await vscode.window.showInputBox({ prompt: `Describe the resolution of ${names}`, value: `Resolve the conflict in ${paths.join(", ")}` }))?.trim();
599 +
  if (!message) {
600 +
    return;
601 +
  }
602 +
  try {
603 +
    await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Committing resolution" }, () => repo.oxy.commit(repo.root, message, paths));
604 +
    remember(repo, message);
605 +
    repo.sourceControl.inputBox.value = "";
606 +
    await repo.refresh();
607 +
    const left = repo.status?.conflicts.length ?? 0;
608 +
    vscode.window.setStatusBarMessage(`$(check) Committed the resolution of ${names}${left ? `; ${left} conflict${left === 1 ? "" : "s"} remain` : ""}`, 5000);
609 +
  } catch (e) {
610 +
    showError(e);
611 +
  }
612 +
}
613 +
614 +
/**
615 +
 * Keep one side of a binary file's or subrepo pin's conflict (oxygen#140):
616 +
 * put that side's version in place, remove the other candidates, and run
617 +
 * `oxy resolve PATH`, which settles it with what the working copy holds.
618 +
 */
619 +
async function keepSide(state: OxyResourceState): Promise<void> {
620 +
  const repo = state.repository;
621 +
  const conflict = repo.status?.conflicts.find((c) => c.path === state.path && c.kind === "replace");
622 +
  if (!conflict) {
623 +
    void vscode.window.showInformationMessage(`Oxygen: ${state.path} has no binary or subrepo conflict to settle.`);
624 +
    return;
625 +
  }
626 +
  const subrepo = repo.subrepos.some((s) => s.path === state.path);
627 +
  const subjects = new Map<string, string>();
628 +
  if (!subrepo) {
629 +
    await Promise.all(
630 +
      (conflict.changes ?? []).map(async (id) => {
631 +
        const info = await repo.oxy.show(repo.root, id).catch(() => undefined);
632 +
        if (info) {
633 +
          subjects.set(id, info.message.split("\n")[0]);
634 +
        }
635 +
      }),
636 +
    );
637 +
  }
638 +
  const pick = await vscode.window.showQuickPick(replaceChoices(conflict, subrepo, subjects), {
639 +
    title: `Settle ${state.path}`,
640 +
    placeHolder: subrepo ? "A subrepo pin moved two ways: keep one pin" : "A binary file changed two ways: keep one version",
641 +
  });
642 +
  if (!pick) {
643 +
    return;
644 +
  }
645 +
  try {
646 +
    if (pick.action === "open-subrepo") {
647 +
      await vscode.commands.executeCommand("vscode.openFolder", vscode.Uri.file(join(repo.root, state.path)), { forceNewWindow: true });
648 +
      return;
649 +
    }
650 +
    if (!subrepo) {
651 +
      // The kept version under the real name; the other candidates would otherwise be committed as new files.
652 +
      const file = join(repo.root, state.path);
653 +
      if (pick.action === "keep-side" && pick.change) {
654 +
        await writeFile(file, await repo.oxy.cat(repo.root, state.path, pick.change));
655 +
      }
656 +
      const dir = dirname(file);
657 +
      for (const c of candidatesOf(basename(file), await readdir(dir))) {
658 +
        await rm(join(dir, c), { recursive: true, force: true });
659 +
      }
660 +
    }
661 +
    const status = await repo.oxy.resolvePaths(repo.root, [state.path]);
662 +
    await repo.refresh();
663 +
    const left = status.conflicts.filter((c) => c.resolved !== true).length;
664 +
    vscode.window.setStatusBarMessage(`$(check) Settled ${state.path}; commit to record the resolution${left ? ` (${left} conflict${left === 1 ? "" : "s"} still to resolve)` : ""}`, 6000);
665 +
  } catch (e) {
666 +
    showError(e);
667 +
  }
668 +
}
669 +
670 +
/**
671 +
 * Settle adjacent insertions, two changes adding lines at the same place,
672 +
 * by keeping every side (oxy resolve --both, oxygen#121). No paths: every
673 +
 * conflicted text file. Conflicts whose sides changed the same lines keep
674 +
 * their markers, and oxy says why.
675 +
 */
676 +
async function keepBothSides(repo: Repository | undefined, paths: string[]): Promise<void> {
677 +
  if (!repo) {
678 +
    return;
679 +
  }
680 +
  const order = await vscode.window.showQuickPick(
681 +
    [
682 +
      { label: "$(arrow-down) Earliest change first", description: "oxy resolve --both", reverse: false },
683 +
      { label: "$(arrow-up) Latest change first", description: "oxy resolve --both --reverse", reverse: true },
684 +
    ],
685 +
    { title: `Keep every side where two changes only add lines at the same place${paths.length ? `: ${paths.join(", ")}` : ""}`, placeHolder: "Order of the kept sides" },
686 +
  );
687 +
  if (!order) {
688 +
    return;
689 +
  }
690 +
  try {
691 +
    const result = await repo.oxy.resolveBoth(repo.root, paths, order.reverse);
534 692
    await repo.refresh();
693 +
    const summary = keptBothSummary(result);
694 +
    if (summary.warn) {
695 +
      void vscode.window.showWarningMessage(`Oxygen: ${summary.message}`);
696 +
    } else {
697 +
      vscode.window.setStatusBarMessage(`$(check) ${summary.message[0].toUpperCase()}${summary.message.slice(1)}; commit to record the resolution`, 6000);
698 +
    }
535 699
  } catch (e) {
536 700
    showError(e);
537 701
  }
…
128 unchanged lines
666 830
    if (pick) {
667 831
      await showChange(repo.root, pick.hash);
668 832
    }
833 +
  } catch (e) {
834 +
    showError(e);
835 +
  }
836 +
}
837 +
838 +
/** Audit the hub's transparency log (oxy tlog verify) and show the result in the Trust view. */
839 +
async function verifyTlog(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
840 +
  const repo = repositoryFor(arg);
841 +
  if (!repo) {
842 +
    return;
843 +
  }
844 +
  try {
845 +
    const info = await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: "Oxygen: verifying the transparency log" }, () => repo.oxy.tlogVerify(repo.root));
846 +
    trustView.setTlog(repo.root, { info, when_ms: Date.now() });
847 +
    const witnesses = info.witnesses.length ? `cosigned by ${info.witnesses.map((w) => w.name).join(", ")}` : "cosigned by no witness you trust";
848 +
    vscode.window.setStatusBarMessage(`$(verified) Transparency log verified up to checkpoint ${info.size}, ${witnesses}`, 6000);
669 849
  } catch (e) {
850 +
    const message = e instanceof OxyError ? e.message : String(e);
851 +
    trustView.setTlog(repo.root, { error: message, when_ms: Date.now() });
670 852
    showError(e);
671 853
  }
672 854
}
…
13 unchanged lines
686 868
      return;
687 869
    }
688 870
    const pick = await vscode.window.showQuickPick(
689 -
      list.map((t) => ({ label: `$(tag) ${t.name}`, description: `${t.heads.length} head${t.heads.length === 1 ? "" : "s"}${t.signer ? ` · signed by ${t.signer}` : ""}`, detail: t.message || undefined, tag: t })),
871 +
      [
872 +
        ...list.map((t) => ({ label: `$(tag) ${t.name}`, description: `${t.heads.length} head${t.heads.length === 1 ? "" : "s"}${t.signer ? ` · signed by ${t.signer}` : ""}`, detail: t.message || undefined, tag: t })),
873 +
        { label: "$(add) Create tag…", alwaysShow: true, action: "create" as const },
874 +
        { label: "$(circle-slash) Withdraw a tag…", alwaysShow: true, action: "withdraw" as const },
875 +
      ],
690 876
      { placeHolder: "Check out a tag's state" },
691 877
    );
692 878
    if (!pick) {
693 879
      return;
694 880
    }
881 +
    if ("action" in pick) {
882 +
      return pick.action === "create" ? createTag(repo) : withdrawTag(repo);
883 +
    }
695 884
    await repo.oxy.checkout(repo.root, [pick.tag.name]);
696 885
    await repo.refresh();
697 886
  } catch (e) {
…
19 unchanged lines
717 906
    await repo.oxy.tagCreate(repo.root, name.trim(), [], message);
718 907
    await repo.refresh();
719 908
    vscode.window.setStatusBarMessage(`$(tag) Tagged ${name.trim()}`, 4000);
909 +
  } catch (e) {
910 +
    showError(e);
911 +
  }
912 +
}
913 +
914 +
/** Withdraw a tag (oxy tag delete): signed, kept and logged, never erased; the name is not used again. */
915 +
async function withdrawTag(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
916 +
  const repo = repositoryFor(arg);
917 +
  if (!repo) {
918 +
    return;
919 +
  }
920 +
  try {
921 +
    const list = await repo.oxy.tags(repo.root);
922 +
    if (list.length === 0) {
923 +
      void vscode.window.showInformationMessage("Oxygen: no tags to withdraw.");
924 +
      return;
925 +
    }
926 +
    const pick = await vscode.window.showQuickPick(
927 +
      list.map((t) => ({ label: `$(tag) ${t.name}`, description: t.signer ? `signed by ${t.signer}` : "", detail: t.message || undefined, name: t.name })),
928 +
      { placeHolder: "Withdraw which tag?" },
929 +
    );
930 +
    if (!pick) {
931 +
      return;
932 +
    }
933 +
    const reason = await vscode.window.showInputBox({ prompt: `Why is ${pick.name} withdrawn? Recorded with the withdrawal.`, placeHolder: "Built from the wrong state", ignoreFocusOut: true });
934 +
    if (reason === undefined) {
935 +
      return;
936 +
    }
937 +
    const q = withdrawQuestion(pick.name);
938 +
    if ((await vscode.window.showWarningMessage(q.message, { modal: true, detail: q.detail }, "Withdraw")) !== "Withdraw") {
939 +
      return;
940 +
    }
941 +
    const out = await repo.oxy.tagWithdraw(repo.root, pick.name, reason.trim());
942 +
    await repo.refresh();
943 +
    vscode.window.setStatusBarMessage(`$(circle-slash) ${out || `Withdrew ${pick.name}`}; push to send the withdrawal`, 6000);
720 944
  } catch (e) {
721 945
    showError(e);
722 946
  }
…
171 unchanged lines
894 1118
  }
895 1119
  const parent = await vscode.window.showOpenDialog({ canSelectFiles: false, canSelectFolders: true, canSelectMany: false, openLabel: "Clone into this folder" });
896 1120
  if (!parent?.[0]) {
1121 +
    return;
1122 +
  }
1123 +
  const history = await vscode.window.showQuickPick(
1124 +
    [
1125 +
      { label: "$(history) Full history", description: "every change", depth: undefined },
1126 +
      { label: "$(zap) Shallow", description: "latest state only, fast for big repositories", detail: "oxy clone --depth 0: the remote's checkpoint; oxy deepen fetches more later", depth: 0 },
1127 +
    ],
1128 +
    { placeHolder: "How much history to clone" },
1129 +
  );
1130 +
  if (!history) {
897 1131
    return;
898 1132
  }
899 1133
  const name = url.trim().replace(/\/+$/, "").split("/").pop()?.replace(/\.oxy$/, "") || "repository";
900 1134
  const dest = vscode.Uri.joinPath(parent[0], name);
901 1135
  try {
902 -
    await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: `Oxygen: cloning ${name}` }, () => oxy.clone(parent[0].fsPath, url.trim(), dest.fsPath));
1136 +
    await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: `Oxygen: cloning ${name}${history.depth === 0 ? " (shallow)" : ""}` }, () =>
1137 +
      oxy.clone(parent[0].fsPath, url.trim(), dest.fsPath, history.depth),
1138 +
    );
903 1139
    const pick = await vscode.window.showInformationMessage(`Oxygen: cloned into ${dest.fsPath}.`, "Open", "Open in new window");
904 1140
    if (pick) {
905 1141
      await vscode.commands.executeCommand("vscode.openFolder", dest, { forceNewWindow: pick === "Open in new window" });
…
515 unchanged lines
1421 1657
    return;
1422 1658
  }
1423 1659
  try {
1424 -
    const name = await pickBookmark(repo, `Merge into ${repo.currentBookmark?.name ?? "the current bookmark"}`, true);
1660 +
    // Remotes' bookmarks too, as last fetched (oxy merge origin/develop, oxygen#117).
1661 +
    const choices = mergeChoices(await repo.oxy.bookmarks(repo.root, true));
1662 +
    if (choices.length === 0) {
1663 +
      void vscode.window.showInformationMessage("Oxygen: no other bookmarks, here or on a remote. Fetch to see a remote's.");
1664 +
      return;
1665 +
    }
1666 +
    const picked = await vscode.window.showQuickPick(
1667 +
      choices.map((c) => ({
1668 +
        label: `${c.remote ? "$(cloud)" : "$(bookmark)"} ${c.name}`,
1669 +
        description: `${c.heads} head${c.heads === 1 ? "" : "s"}${c.remote ? ` · on ${c.remote}, as last fetched` : ""}`,
1670 +
        name: c.name,
1671 +
      })),
1672 +
      { placeHolder: `Merge into ${repo.currentBookmark?.name ?? "the current bookmark"}`, matchOnDescription: true },
1673 +
    );
1674 +
    const name = picked?.name;
1425 1675
    if (!name) {
1426 1676
      return;
1427 1677
    }
…
200 unchanged lines
modified src/oxy.ts +103 −6
…
136 unchanged lines
137 137
    return this.check(root, ["commit", "-m", message, ...paths]).then(() => undefined);
138 138
  }
139 139
140 -
  bookmarks(root: string): Promise<BookmarkInfo[]> {
141 -
    return this.json<BookmarkInfo[]>(root, ["bookmark", "list"]);
140 +
  /** The local bookmarks; with `all`, remotes' bookmarks with no local one too, named `REMOTE/NAME`. */
141 +
  bookmarks(root: string, all = false): Promise<BookmarkInfo[]> {
142 +
    return this.json<BookmarkInfo[]>(root, ["bookmark", "list", ...(all ? ["--all"] : [])]);
142 143
  }
143 144
144 145
  /** Restore paths to the working copy's base. */
…
13 unchanged lines
158 159
    return this.check(root, ["commit", "-m", message, "--hunks", hunks.join(",")]).then(() => undefined);
159 160
  }
160 161
161 -
  amend(root: string, message?: string): Promise<void> {
162 +
  /** Fold the working copy into the head it sits on; with `paths`, only those (the rest stays uncommitted). */
163 +
  amend(root: string, message?: string, paths: string[] = []): Promise<void> {
162 164
    const args = ["amend"];
163 165
    if (message) {
164 166
      args.push("-m", message);
165 167
    }
166 -
    return this.check(root, args).then(() => undefined);
168 +
    return this.check(root, [...args, ...paths]).then(() => undefined);
167 169
  }
168 170
169 171
  undo(root: string): Promise<string> {
…
22 unchanged lines
192 194
    return this.check(root, ["bookmark", "delete", name]).then(() => undefined);
193 195
  }
194 196
197 +
  /** Union a bookmark's heads into the current one: a local bookmark, or a remote's (`origin/develop`) as last fetched. */
195 198
  merge(root: string, name: string): Promise<string> {
196 199
    return this.check(root, ["merge", name]).then((r) => r.stdout.toString("utf8").trim());
197 200
  }
…
23 unchanged lines
221 224
    return this.json<PushResult[]>(root, args, [0, 1]);
222 225
  }
223 226
224 -
  clone(cwd: string, url: string, dest: string): Promise<void> {
225 -
    return this.check(cwd, ["clone", url, dest]).then(() => undefined);
227 +
  /** Clone `url` into `dest`; `depth` makes it shallow (0: the latest state only, from the remote's checkpoint). */
228 +
  clone(cwd: string, url: string, dest: string, depth?: number): Promise<void> {
229 +
    return this.check(cwd, ["clone", ...(depth !== undefined ? ["--depth", String(depth)] : []), url, dest]).then(() => undefined);
226 230
  }
227 231
228 232
  // ---------------------------------------------------------------- history and blame
…
45 unchanged lines
274 278
275 279
  tagCreate(root: string, name: string, changes: string[] = [], message = ""): Promise<void> {
276 280
    return this.check(root, ["tag", "create", name, ...changes, "-m", message]).then(() => undefined);
281 +
  }
282 +
283 +
  /**
284 +
   * Withdraw a tag (`oxy tag delete`): a signed withdrawal kept beside the
285 +
   * tag's creation, which push sends on; the name is never used again.
286 +
   * Returns oxy's summary line.
287 +
   */
288 +
  tagWithdraw(root: string, name: string, reason: string): Promise<string> {
289 +
    return this.check(root, ["tag", "delete", name, "-r", reason]).then((r) => r.stdout.toString("utf8").trim().split("\n")[0].replace(/^\* /, ""));
277 290
  }
291 +
292 +
  // ---------------------------------------------------------------- settling conflicts
278 293
294 +
  /**
295 +
   * Settle the replace conflicts (binary files, subrepo pins) at `paths`
296 +
   * with what the working copy holds there (oxygen#140); the next commit
297 +
   * records the resolution. Returns the status after it.
298 +
   */
299 +
  resolvePaths(root: string, paths: string[]): Promise<StatusInfo> {
300 +
    return this.json<StatusInfo>(root, ["resolve", ...paths], [0, 2]);
301 +
  }
302 +
303 +
  /**
304 +
   * Settle adjacent insertions (two changes adding lines at the same place)
305 +
   * by keeping every side, earliest change first, or latest with `reverse`
306 +
   * (oxygen#121). No paths: every conflicted text file. What was kept and
307 +
   * what keeps its markers, and why.
308 +
   */
309 +
  resolveBoth(root: string, paths: string[] = [], reverse = false): Promise<KeptBoth> {
310 +
    return this.check(root, ["resolve", "--both", ...(reverse ? ["--reverse"] : []), ...paths], [0, 2]).then((r) => parseKeptBoth(r.stdout.toString("utf8")));
311 +
  }
312 +
313 +
  // ---------------------------------------------------------------- transparency log
314 +
315 +
  /**
316 +
   * Audit a hub's whole transparency log (`oxy tlog verify`): its checkpoint
317 +
   * against the log key and the last one seen, and every entry against the
318 +
   * root; then this repository's entries. `hub` defaults to the remote.
319 +
   */
320 +
  tlogVerify(root: string, hub?: string): Promise<TlogInfo> {
321 +
    return this.json<TlogInfo>(root, ["tlog", "verify", ...(hub ? [hub] : [])]);
322 +
  }
323 +
279 324
  // ---------------------------------------------------------------- subrepos, bisect, trust, tags, workspaces
280 325
281 326
  submodules(root: string): Promise<SubrepoInfo[]> {
…
81 unchanged lines
363 408
  name: string | null;
364 409
  revoked: boolean;
365 410
  reason: string | null;
411 +
}
412 +
413 +
/** What `oxy resolve --both` did: the conflicts kept on every side, and those left with markers. */
414 +
export interface KeptBoth {
415 +
  kept: { path: string; conflicts: number[] }[];
416 +
  left: { path: string; conflict: number; why: string }[];
417 +
}
418 +
419 +
/** Read `oxy resolve --both`'s report lines (`kept both  PATH: conflict 1, 2`, `markers    PATH: conflict 3: why`). */
420 +
export function parseKeptBoth(text: string): KeptBoth {
421 +
  const out: KeptBoth = { kept: [], left: [] };
422 +
  for (const line of text.split(/\r?\n/)) {
423 +
    const kept = /^kept both\s+(.+): conflicts? ([\d, ]+)$/.exec(line);
424 +
    if (kept) {
425 +
      out.kept.push({ path: kept[1], conflicts: kept[2].split(",").map((n) => Number(n.trim())).filter((n) => n > 0) });
426 +
      continue;
427 +
    }
428 +
    const left = /^markers\s+(.+): conflict (\d+): (.+)$/.exec(line);
429 +
    if (left) {
430 +
      out.left.push({ path: left[1], conflict: Number(left[2]), why: left[3] });
431 +
    }
432 +
  }
433 +
  return out;
434 +
}
435 +
436 +
/** `oxy tlog verify --json`: the checkpoint verified, who cosigned it, and the entries listed. */
437 +
export interface TlogInfo {
438 +
  hub: string;
439 +
  origin: string;
440 +
  /** Entries in the log the checkpoint covers. */
441 +
  size: number;
442 +
  root: string;
443 +
  witnesses: { name: string; time_ms: number }[];
444 +
  entries: { index: number; kind: string; repo: string; object: string; signers: string[]; received_ms: number }[];
445 +
}
446 +
447 +
/**
448 +
 * A remote's bookmark that `bookmark list --all` adds because there is no
449 +
 * local bookmark of that name: named `REMOTE/NAME`, never current, with no
450 +
 * ahead or behind counts. `oxy merge` takes the name as it is.
451 +
 */
452 +
export function isRemoteBookmark(b: BookmarkInfo): boolean {
453 +
  return !!b.remote && b.name.startsWith(`${b.remote}/`) && !b.current && b.ahead === null && b.behind === null;
454 +
}
455 +
456 +
/** What Merge Bookmark offers: the other local bookmarks first, then remotes' bookmarks. */
457 +
export function mergeChoices(bookmarks: BookmarkInfo[]): { name: string; remote: string | null; heads: number }[] {
458 +
  const local = bookmarks.filter((b) => !b.current && !isRemoteBookmark(b)).map((b) => ({ name: b.name, remote: null, heads: b.heads.length }));
459 +
  const remote = bookmarks.filter(isRemoteBookmark).map((b) => ({ name: b.name, remote: b.remote ?? null, heads: b.heads.length }));
460 +
  return [...local, ...remote];
366 461
}
367 462
368 463
export interface TagInfo {
…
128 unchanged lines
497 592
  current: boolean;
498 593
  ahead: number | null;
499 594
  behind: number | null;
595 +
  /** The remote it tracks, or for a remote's bookmark (`--all`) the remote it is on. */
596 +
  remote?: string | null;
500 597
}
modified src/panels.ts +15 −1
1 1
// The Bisect and Trust views (spec §5.11, §5.12): small trees fed by
2 -
// `oxy bisect status --json`, `oxy policy show --json` and `oxy verify --json`.
2 +
// `oxy bisect status --json`, `oxy policy show --json`, `oxy verify --json`
3 +
// and, when asked, `oxy tlog verify --json`.
3 4
4 5
import * as vscode from "vscode";
5 6
import { BisectStatus, PolicyInfo, VerifyInfo } from "./oxy";
6 7
import { Repository } from "./repository";
7 8
import { icon as tinted } from "./style";
9 +
import { TlogCheck, tlogLines } from "./tlog";
10 +
import { timeAgo } from "./operations";
8 11
9 12
class Line extends vscode.TreeItem {
10 13
  constructor(label: string, opts: { description?: string; icon?: string; color?: string; command?: vscode.Command; tooltip?: string; context?: string } = {}) {
…
108 unchanged lines
119 122
}
120 123
121 124
export class TrustProvider extends RepoTree {
125 +
  /** The last transparency log check per repository root; the log is checked on request, not on every refresh (it asks the hub). */
126 +
  private readonly tlogChecks = new Map<string, TlogCheck>();
127 +
128 +
  setTlog(root: string, check: TlogCheck): void {
129 +
    this.tlogChecks.set(root, check);
130 +
    this.refresh();
131 +
  }
132 +
122 133
  protected async items(repo: Repository): Promise<vscode.TreeItem[]> {
123 134
    let policy: PolicyInfo | null;
124 135
    let verify: VerifyInfo[];
…
35 unchanged lines
160 171
        command: { title: "Verify", command: "oxygen.verify", arguments: [repo] },
161 172
      }),
162 173
    );
174 +
    for (const l of tlogLines(this.tlogChecks.get(repo.root), timeAgo)) {
175 +
      items.push(new Line(l.label, { icon: l.icon, description: l.description, tooltip: l.tooltip, command: { title: "Verify transparency log", command: "oxygen.verifyTlog", arguments: [repo] } }));
176 +
    }
163 177
    return items;
164 178
  }
165 179
}
modified src/repository.ts +7 −0
…
228 unchanged lines
229 229
          conflicts.push(new OxyResourceState(this, c.path, "ConflictEdited"));
230 230
        }
231 231
      }
232 +
      // A binary file's or subrepo pin's conflict has no markers: it offers Keep Side instead.
233 +
      const replace = new Set(status.conflicts.filter((c) => c.kind === "replace" && c.resolved !== true && c.path).map((c) => c.path as string));
234 +
      for (const state of conflicts) {
235 +
        if (replace.has(state.path) && state.status !== "Resolved") {
236 +
          state.contextValue = "replace";
237 +
        }
238 +
      }
232 239
      this.conflictsGroup.resourceStates = conflicts;
233 240
      this.changesGroup.resourceStates = changes;
234 241
      this.sourceControl.count = status.paths.length + (status.conflicts.length ? status.conflicts.length : 0);
…
65 unchanged lines
modified test/oxy.test.mjs +165 −2
…
2 unchanged lines
3 3
4 4
import { test } from "node:test";
5 5
import assert from "node:assert/strict";
6 -
import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
6 +
import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs";
7 7
import { tmpdir } from "node:os";
8 8
import { join } from "node:path";
9 -
import { Oxy, findBinary } from "../out/oxy.mjs";
9 +
import { Oxy, findBinary, isRemoteBookmark, mergeChoices, parseKeptBoth } from "../out/oxy.mjs";
10 10
11 11
const binary = process.env.OXY_BIN ?? findBinary(undefined);
12 12
const oxy = new Oxy(binary);
13 13
14 +
/** Whether the oxy under test is at least `min` (a feature newer than the oldest oxy the tests run with). */
15 +
const oxyVersion = (await oxy.version()).version;
16 +
function atLeast(min) {
17 +
  const [a, b] = [oxyVersion, min].map((v) => v.split(/[.-]/).slice(0, 3).map(Number));
18 +
  for (let i = 0; i < 3; i++) {
19 +
    if (a[i] !== b[i]) {
20 +
      return a[i] > b[i];
21 +
    }
22 +
  }
23 +
  return true;
24 +
}
25 +
14 26
test("root, status, cat, log and commit round trip", async () => {
15 27
  const dir = mkdtempSync(join(tmpdir(), "oxy-vscode-"));
16 28
  const home = join(dir, "home");
…
178 unchanged lines
195 207
  assert.equal(id?.who, "Test <[email protected]>");
196 208
  assert.equal(id?.secret, true);
197 209
  assert.ok(id?.mode === "change" || id?.mode === "seal");
210 +
});
211 +
212 +
213 +
// ---------------------------------------------------------------- oxy 0.5 to 0.9 (oxygen-vscode#11)
214 +
215 +
test("oxy resolve --both's report is read", () => {
216 +
  const text = [
217 +
    "kept both  CHANGELOG.md: conflict 1, 3",
218 +
    "markers    CHANGELOG.md: conflict 2: both sides changed these lines; edit the markers",
219 +
    "resolved   CHANGELOG.md",
220 +
    "markers    src/a.rs",
221 +
    "All conflicts are resolved; `oxy commit` will record the resolution.",
222 +
  ].join("\n");
223 +
  assert.deepEqual(parseKeptBoth(text), {
224 +
    kept: [{ path: "CHANGELOG.md", conflicts: [1, 3] }],
225 +
    left: [{ path: "CHANGELOG.md", conflict: 2, why: "both sides changed these lines; edit the markers" }],
226 +
  });
227 +
  assert.deepEqual(parseKeptBoth(""), { kept: [], left: [] });
228 +
});
229 +
230 +
test("Merge Bookmark offers the other local bookmarks, then remotes' bookmarks", () => {
231 +
  const list = [
232 +
    { name: "main", heads: ["a"], current: true, ahead: 0, behind: 0, remote: "origin" },
233 +
    { name: "feature/x", heads: ["b"], current: false, ahead: null, behind: null, remote: null },
234 +
    { name: "origin/develop", heads: ["c", "d"], current: false, ahead: null, behind: null, remote: "origin" },
235 +
  ];
236 +
  assert.deepEqual(list.map(isRemoteBookmark), [false, false, true]);
237 +
  assert.deepEqual(mergeChoices(list), [
238 +
    { name: "feature/x", remote: null, heads: 1 },
239 +
    { name: "origin/develop", remote: "origin", heads: 2 },
240 +
  ]);
241 +
});
242 +
243 +
test("tags are withdrawn, signed and never reused", async () => {
244 +
  const dir = mkdtempSync(join(tmpdir(), "oxy-vscode-tag-"));
245 +
  const home = join(dir, "home");
246 +
  mkdirSync(home);
247 +
  process.env.OXY_HOME = home;
248 +
  await oxy.check(dir, ["init", "repo"]);
249 +
  const root = join(dir, "repo");
250 +
  await oxy.check(root, ["identity", "create", "--name", "Test", "--email", "[email protected]"]);
251 +
  writeFileSync(join(root, "a.txt"), "a\n");
252 +
  await oxy.commit(root, "one");
253 +
  await oxy.tagCreate(root, "v1", [], "first");
254 +
  assert.match(await oxy.tagWithdraw(root, "v1", "built from the wrong state"), /Withdrew v1/);
255 +
  assert.deepEqual(await oxy.tags(root), [], "a withdrawn tag is not listed");
256 +
  await assert.rejects(oxy.tagCreate(root, "v1", [], "again"), /withdrawn/);
257 +
  // No remote, so no hub whose transparency log to check: oxy says so.
258 +
  await assert.rejects(oxy.tlogVerify(root), /hub/);
259 +
});
260 +
261 +
test("shallow clone, remote bookmarks merged, amend and commit by paths", async () => {
262 +
  const dir = mkdtempSync(join(tmpdir(), "oxy-vscode-paths-"));
263 +
  const home = join(dir, "home");
264 +
  mkdirSync(home);
265 +
  process.env.OXY_HOME = home;
266 +
  await oxy.check(dir, ["init", "origin"]);
267 +
  const origin = join(dir, "origin");
268 +
  await oxy.check(origin, ["identity", "create", "--name", "Test", "--email", "[email protected]"]);
269 +
  writeFileSync(join(origin, "a.txt"), "a\n");
270 +
  await oxy.commit(origin, "one");
271 +
  await oxy.clone(dir, origin, join(dir, "work"), 0);
272 +
  const root = join(dir, "work");
273 +
  assert.equal(readFileSync(join(root, "a.txt"), "utf8"), "a\n", "the shallow clone has the latest state");
274 +
275 +
  // Amend only one path: the other stays uncommitted work.
276 +
  writeFileSync(join(root, "a.txt"), "a\nmore\n");
277 +
  await oxy.commit(root, "more");
278 +
  writeFileSync(join(root, "a.txt"), "a\nmore\nagain\n");
279 +
  writeFileSync(join(root, "c.txt"), "c\n");
280 +
  await oxy.amend(root, undefined, ["a.txt"]);
281 +
  const status = await oxy.status(root);
282 +
  assert.deepEqual(status.paths.map((p) => p.path), ["c.txt"], "c.txt was not folded in");
283 +
  assert.equal((await oxy.cat(root, "a.txt")).toString(), "a\nmore\nagain\n");
284 +
  rmSync(join(root, "c.txt"));
285 +
286 +
  // A bookmark made on the origin is merged here as origin/feat, without a local bookmark.
287 +
  await oxy.bookmarkCreate(origin, "feat");
288 +
  await oxy.bookmarkSwitch(origin, "feat");
289 +
  writeFileSync(join(origin, "f.txt"), "f\n");
290 +
  await oxy.commit(origin, "feat work");
291 +
  await oxy.fetch(root);
292 +
  const all = await oxy.bookmarks(root, true);
293 +
  assert.deepEqual(mergeChoices(all).map((c) => [c.name, c.remote]), [["origin/feat", "origin"]]);
294 +
  assert.deepEqual((await oxy.bookmarks(root)).map((b) => b.name), ["main"], "without --all, local bookmarks only");
295 +
  assert.match(await oxy.merge(root, "origin/feat"), /Merged/);
296 +
  assert.equal(readFileSync(join(root, "f.txt"), "utf8"), "f\n");
297 +
});
298 +
299 +
/** A repository whose main and other bookmarks changed `t.txt` (inserting after the same line) and `bin.dat` two ways, merged. */
300 +
async function conflicted(prefix, text) {
301 +
  const dir = mkdtempSync(join(tmpdir(), prefix));
302 +
  const home = join(dir, "home");
303 +
  mkdirSync(home);
304 +
  process.env.OXY_HOME = home;
305 +
  await oxy.check(dir, ["init", "repo"]);
306 +
  const root = join(dir, "repo");
307 +
  await oxy.check(root, ["identity", "create", "--name", "Test", "--email", "[email protected]"]);
308 +
  writeFileSync(join(root, "t.txt"), "a\nb\n");
309 +
  writeFileSync(join(root, "u.txt"), "a\nb\n");
310 +
  writeFileSync(join(root, "bin.dat"), Buffer.from([0, 1, 2, 3]));
311 +
  await oxy.commit(root, "base");
312 +
  await oxy.bookmarkCreate(root, "other");
313 +
  writeFileSync(join(root, "t.txt"), text("X"));
314 +
  writeFileSync(join(root, "u.txt"), "a\nX\n");
315 +
  writeFileSync(join(root, "bin.dat"), Buffer.from([0, 1, 2, 4]));
316 +
  await oxy.commit(root, "main side");
317 +
  await oxy.bookmarkSwitch(root, "other");
318 +
  writeFileSync(join(root, "t.txt"), text("Y"));
319 +
  writeFileSync(join(root, "u.txt"), "a\nY\n");
320 +
  writeFileSync(join(root, "bin.dat"), Buffer.from([0, 1, 2, 5]));
321 +
  await oxy.commit(root, "other side");
322 +
  await oxy.bookmarkSwitch(root, "main");
323 +
  await oxy.merge(root, "other");
324 +
  return root;
325 +
}
326 +
327 +
test("a binary file's conflict is settled by keeping one side, then its resolution committed by path", async () => {
328 +
  const root = await conflicted("oxy-vscode-keep-", (s) => `a\n${s}\n`);
329 +
  let status = await oxy.status(root);
330 +
  const bin = status.conflicts.find((c) => c.path === "bin.dat");
331 +
  assert.equal(bin.kind, "replace");
332 +
  assert.equal(bin.changes.length, 2);
333 +
  // Keep side 2 as Keep a Side does: its version in place, candidates gone, oxy resolve PATH.
334 +
  const side = await oxy.cat(root, "bin.dat", bin.changes[1]);
335 +
  writeFileSync(join(root, "bin.dat"), side);
336 +
  for (const c of readdirSync(root).filter((e) => e.startsWith("bin.dat.oxy-conflict-"))) {
337 +
    rmSync(join(root, c));
338 +
  }
339 +
  status = await oxy.resolvePaths(root, ["bin.dat"]);
340 +
  assert.equal(status.conflicts.find((c) => c.path === "bin.dat").resolved, true);
341 +
  assert.ok(status.conflicts.some((c) => c.path !== "bin.dat" && c.resolved !== true), "the text conflicts remain");
342 +
  await assert.rejects(oxy.resolvePaths(root, ["u.txt"]), /no replace conflict/);
343 +
344 +
  // Commit Resolution of Selected Paths: only bin.dat; the text conflicts stay for later.
345 +
  await oxy.commit(root, "keep the other logo", ["bin.dat"]);
346 +
  status = await oxy.status(root);
347 +
  assert.equal(status.conflicts.some((c) => c.path === "bin.dat"), false);
348 +
  assert.deepEqual(status.conflicts.map((c) => c.path).sort(), ["t.txt", "u.txt"]);
349 +
  assert.deepEqual([...(await oxy.cat(root, "bin.dat"))], [...side]);
350 +
});
351 +
352 +
test("two changes adding lines at the same place are settled with one command", { skip: !atLeast("0.8.0") && `oxy ${oxyVersion} has no resolve --both` }, async () => {
353 +
  const root = await conflicted("oxy-vscode-both-", (s) => `a\n${s}\nb\n`);
354 +
  const result = await oxy.resolveBoth(root, ["t.txt", "u.txt"]);
355 +
  assert.deepEqual(result.kept, [{ path: "t.txt", conflicts: [1] }]);
356 +
  assert.deepEqual(result.left.map((l) => [l.path, l.conflict]), [["u.txt", 1]], "u.txt's sides changed the same line");
357 +
  assert.match(result.left[0].why, /same lines|edit the markers/);
358 +
  assert.equal(readFileSync(join(root, "t.txt"), "utf8"), "a\nX\nY\nb\n", "earliest change first");
359 +
  const status = await oxy.status(root);
360 +
  assert.equal(status.conflicts.find((c) => c.path === "t.txt").resolved, true);
198 361
});