OXYGENHUB

Milestone 0.6: Subrepos group, Bisect and Trust views, verify, tags, add workspace, clone d2c9a5843004

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-27 16:02 UTC
Signature
✔ signed by Andy McCall
Hash
d2c9a584300473178e154b49af4792d7b4eb521fdd1f18138af0a664340828f8
Depends on
25103dddd993 35d56b5803d9 ab81294a9ba9 bed495a96408 c8f955891224
Depended on by
dac6876e4d08 b55e0d00545e 013fefef11e3 0c26846a9a81 f532c4266f21 b4626290e660 f6bda77ecb9f
In bookmarks
main
In tags
v0.16.0 v0.16.1 v0.17.0 v0.17.1 v0.18.0
Edits
28 atoms, 1 tree operation, 7 files

Browse the tree at this change

added src/panels.ts +142 −0
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`.
3 +
4 +
import * as vscode from "vscode";
5 +
import { BisectStatus, PolicyInfo, VerifyInfo } from "./oxy";
6 +
import { Repository } from "./repository";
7 +
8 +
class Line extends vscode.TreeItem {
9 +
  constructor(label: string, opts: { description?: string; icon?: string; command?: vscode.Command; tooltip?: string; context?: string } = {}) {
10 +
    super(label, vscode.TreeItemCollapsibleState.None);
11 +
    this.description = opts.description;
12 +
    this.tooltip = opts.tooltip;
13 +
    this.command = opts.command;
14 +
    this.contextValue = opts.context;
15 +
    if (opts.icon) {
16 +
      this.iconPath = new vscode.ThemeIcon(opts.icon);
17 +
    }
18 +
  }
19 +
}
20 +
21 +
abstract class RepoTree implements vscode.TreeDataProvider<vscode.TreeItem> {
22 +
  private readonly emitter = new vscode.EventEmitter<void>();
23 +
  readonly onDidChangeTreeData = this.emitter.event;
24 +
25 +
  constructor(protected readonly repositories: () => Repository[]) {}
26 +
27 +
  refresh(): void {
28 +
    this.emitter.fire();
29 +
  }
30 +
31 +
  getTreeItem(element: vscode.TreeItem): vscode.TreeItem {
32 +
    return element;
33 +
  }
34 +
35 +
  async getChildren(element?: vscode.TreeItem): Promise<vscode.TreeItem[]> {
36 +
    const repos = this.repositories();
37 +
    if (!element) {
38 +
      if (repos.length === 1) {
39 +
        return this.items(repos[0]);
40 +
      }
41 +
      return repos.map((r) => {
42 +
        const item = new vscode.TreeItem(r.root.split("/").pop() ?? r.root, vscode.TreeItemCollapsibleState.Expanded);
43 +
        item.iconPath = new vscode.ThemeIcon("repo");
44 +
        (item as vscode.TreeItem & { repository?: Repository }).repository = r;
45 +
        return item;
46 +
      });
47 +
    }
48 +
    const repo = (element as vscode.TreeItem & { repository?: Repository }).repository;
49 +
    return repo ? this.items(repo) : [];
50 +
  }
51 +
52 +
  protected abstract items(repo: Repository): Promise<vscode.TreeItem[]>;
53 +
}
54 +
55 +
export class BisectProvider extends RepoTree {
56 +
  protected async items(repo: Repository): Promise<vscode.TreeItem[]> {
57 +
    let status: BisectStatus | null;
58 +
    try {
59 +
      status = await repo.oxy.bisectStatus(repo.root);
60 +
    } catch (e) {
61 +
      return [new Line(String(e), { icon: "warning" })];
62 +
    }
63 +
    const cmd = (title: string, command: string, args: unknown[] = []): vscode.Command => ({ title, command, arguments: [repo, ...args] });
64 +
    if (!status) {
65 +
      return [new Line("No bisect in progress", { icon: "search", description: "start one to find a regression", command: cmd("Start", "oxygen.bisectStart") })];
66 +
    }
67 +
    switch (status.state) {
68 +
      case "need-marks":
69 +
        return [
70 +
          new Line(`Marked ${status.good} good, ${status.bad} bad`, { icon: "search" }),
71 +
          new Line(status.good === 0 ? "Mark a known-good change or bookmark…" : "Mark a known-bad change or bookmark…", { icon: status.good === 0 ? "pass" : "error", command: cmd("Mark", status.good === 0 ? "oxygen.bisectGood" : "oxygen.bisectBad") }),
72 +
          new Line("Reset", { icon: "discard", command: cmd("Reset", "oxygen.bisectReset") }),
73 +
        ];
74 +
      case "testing":
75 +
        return [
76 +
          new Line(`Testing: ${status.remaining} candidates left`, { icon: "beaker", description: `about ${status.steps_left} more steps` }),
77 +
          new Line("This state is good", { icon: "pass", command: cmd("Good", "oxygen.bisectGood", [[]]) }),
78 +
          new Line("This state is bad", { icon: "error", command: cmd("Bad", "oxygen.bisectBad", [[]]) }),
79 +
          new Line("Skip: cannot test this state", { icon: "debug-step-over", command: cmd("Skip", "oxygen.bisectSkip") }),
80 +
          new Line("Run a command for every step…", { icon: "run-all", command: cmd("Run", "oxygen.bisectRun") }),
81 +
          new Line("Reset", { icon: "discard", command: cmd("Reset", "oxygen.bisectReset") }),
82 +
        ];
83 +
      case "found":
84 +
        return [
85 +
          new Line(`Found: ${status.culprit.slice(0, 12)}`, { icon: "bug", description: "the first change that makes the state bad", command: { title: "Show", command: "oxygen.showChange", arguments: [repo.root, status.culprit] } }),
86 +
          new Line("Reset: back to where you were", { icon: "discard", command: cmd("Reset", "oxygen.bisectReset") }),
87 +
        ];
88 +
      case "inconclusive":
89 +
        return [
90 +
          new Line(`Inconclusive: ${status.candidates.length} candidates could not be told apart`, { icon: "question" }),
91 +
          ...status.candidates.map((c) => new Line(c.slice(0, 12), { icon: "circle-outline", command: { title: "Show", command: "oxygen.showChange", arguments: [repo.root, c] } })),
92 +
          new Line("Reset", { icon: "discard", command: cmd("Reset", "oxygen.bisectReset") }),
93 +
        ];
94 +
    }
95 +
  }
96 +
}
97 +
98 +
export class TrustProvider extends RepoTree {
99 +
  protected async items(repo: Repository): Promise<vscode.TreeItem[]> {
100 +
    let policy: PolicyInfo | null;
101 +
    let verify: VerifyInfo[];
102 +
    try {
103 +
      [policy, verify] = await Promise.all([repo.oxy.policyShow(repo.root), repo.oxy.verify(repo.root)]);
104 +
    } catch (e) {
105 +
      return [new Line(String(e), { icon: "warning" })];
106 +
    }
107 +
    const items: vscode.TreeItem[] = [];
108 +
    if (!policy) {
109 +
      items.push(new Line("No trust policy: this repository is unverified", { icon: "unlock", description: "oxy policy init creates one", tooltip: "Without a policy every change and reference update is accepted." }));
110 +
    } else {
111 +
      const expires = new Date(policy.expires_ms).toLocaleDateString();
112 +
      items.push(new Line(`Policy serial ${policy.serial}`, { icon: policy.expired ? "error" : "shield", description: policy.expired ? `EXPIRED ${expires}` : `expires ${expires}` }));
113 +
      for (const r of policy.roots) {
114 +
        items.push(new Line(`Root: ${r.name || r.key.slice(0, 12)}`, { icon: "key", description: r.email, tooltip: r.key }));
115 +
      }
116 +
      items.push(new Line(`Root threshold ${policy.root_threshold}`, { icon: "law" }));
117 +
      for (const rule of policy.rules) {
118 +
        items.push(new Line(`Rule ${rule.pattern}`, { icon: "list-tree", description: `${rule.keys.map((k) => k.name || k.key.slice(0, 8)).join(", ")} · threshold ${rule.threshold} · ${rule.require}${rule.allow_conflicts ? "" : " · no conflicts"}` }));
119 +
      }
120 +
      for (const r of policy.revoked) {
121 +
        items.push(new Line(`Revoked: ${r.name || r.key.slice(0, 12)}`, { icon: "circle-slash" }));
122 +
      }
123 +
    }
124 +
    const counts = { signed: 0, sealed: 0, unsigned: 0, invalid: 0, revoked: 0 };
125 +
    for (const v of verify) {
126 +
      if (v.status in counts) {
127 +
        counts[v.status as keyof typeof counts] += 1;
128 +
      }
129 +
      if (v.revoked) {
130 +
        counts.revoked += 1;
131 +
      }
132 +
    }
133 +
    items.push(
134 +
      new Line(`${verify.length} changes verified`, {
135 +
        icon: counts.invalid > 0 ? "error" : "verified",
136 +
        description: `${counts.signed} signed, ${counts.sealed} sealed, ${counts.unsigned} unsigned${counts.invalid ? `, ${counts.invalid} INVALID` : ""}${counts.revoked ? `, ${counts.revoked} by revoked keys` : ""}`,
137 +
        command: { title: "Verify", command: "oxygen.verify", arguments: [repo] },
138 +
      }),
139 +
    );
140 +
    return items;
141 +
  }
142 +
}
modified CHANGELOG.md +4 −0
1 1
# Changelog
2 2
3 +
## 0.6.0 (2026-09-27)
4 +
5 +
Milestone 0.6: a Subrepos group with update and open, Oxygen Bisect and Oxygen Trust views, Verify Signatures, tag picker and Create Tag, Add Workspace, and Clone Repository.
6 +
3 7
## 0.5.0 (2026-09-27)
4 8
5 9
Milestone 0.5: conflict editor CodeLens over oxy markers (accept a side, all sides, or a deletion), keep-this-candidate for tree conflicts, Mark Resolved, and the commit action reads "Commit resolution" once every conflict is resolved.
…
28 unchanged lines
modified package.json +35 −5
…
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.5.0",
5 +
  "version": "0.6.0",
6 6
  "publisher": "oxygen",
7 7
  "license": "MIT OR Apache-2.0",
8 8
  "icon": "assets/images/oxygen.png",
…
48 unchanged lines
57 57
      { "command": "oxygen.acceptSides", "title": "Accept Conflict Sides", "category": "Oxygen" },
58 58
      { "command": "oxygen.keepCandidate", "title": "Keep This Candidate", "category": "Oxygen", "icon": "$(check)" },
59 59
      { "command": "oxygen.markResolved", "title": "Mark Resolved", "category": "Oxygen", "icon": "$(check-all)" },
60 -
      { "command": "oxygen.openConflict", "title": "Open Conflict", "category": "Oxygen" }
60 +
      { "command": "oxygen.openConflict", "title": "Open Conflict", "category": "Oxygen" },
61 +
      { "command": "oxygen.openSubrepo", "title": "Open Subrepo", "category": "Oxygen" },
62 +
      { "command": "oxygen.updateSubrepo", "title": "Update to Pin", "category": "Oxygen", "icon": "$(cloud-download)" },
63 +
      { "command": "oxygen.updateSubrepos", "title": "Update Subrepos", "category": "Oxygen" },
64 +
      { "command": "oxygen.bisectStart", "title": "Bisect: Start", "category": "Oxygen" },
65 +
      { "command": "oxygen.bisectGood", "title": "Bisect: Good", "category": "Oxygen" },
66 +
      { "command": "oxygen.bisectBad", "title": "Bisect: Bad", "category": "Oxygen" },
67 +
      { "command": "oxygen.bisectSkip", "title": "Bisect: Skip", "category": "Oxygen" },
68 +
      { "command": "oxygen.bisectRun", "title": "Bisect: Run…", "category": "Oxygen" },
69 +
      { "command": "oxygen.bisectReset", "title": "Bisect: Reset", "category": "Oxygen" },
70 +
      { "command": "oxygen.refreshBisect", "title": "Refresh", "category": "Oxygen", "icon": "$(refresh)" },
71 +
      { "command": "oxygen.refreshTrust", "title": "Refresh", "category": "Oxygen", "icon": "$(refresh)" },
72 +
      { "command": "oxygen.verify", "title": "Verify Signatures", "category": "Oxygen" },
73 +
      { "command": "oxygen.tags", "title": "Tags…", "category": "Oxygen" },
74 +
      { "command": "oxygen.createTag", "title": "Create Tag…", "category": "Oxygen" },
75 +
      { "command": "oxygen.addWorkspace", "title": "Add Workspace…", "category": "Oxygen" },
76 +
      { "command": "oxygen.clone", "title": "Clone Repository…", "category": "Oxygen" }
61 77
    ],
62 78
    "views": {
63 79
      "scm": [
64 -
        { "id": "oxygen.operations", "name": "Oxygen Operations", "when": "oxygen.hasRepository", "icon": "assets/images/oxygen.svg", "visibility": "collapsed" }
80 +
        { "id": "oxygen.operations", "name": "Oxygen Operations", "when": "oxygen.hasRepository", "icon": "assets/images/oxygen.svg", "visibility": "collapsed" },
81 +
        { "id": "oxygen.bisect", "name": "Oxygen Bisect", "when": "oxygen.hasRepository", "icon": "assets/images/oxygen.svg", "visibility": "collapsed" },
82 +
        { "id": "oxygen.trust", "name": "Oxygen Trust", "when": "oxygen.hasRepository", "icon": "assets/images/oxygen.svg", "visibility": "collapsed" }
65 83
      ]
66 84
    },
67 85
    "viewsWelcome": [
…
16 unchanged lines
84 102
        { "command": "oxygen.createBookmark", "group": "5_bookmarks@2", "when": "scmProvider == oxygen" },
85 103
        { "command": "oxygen.mergeBookmark", "group": "5_bookmarks@3", "when": "scmProvider == oxygen" },
86 104
        { "command": "oxygen.deleteBookmark", "group": "5_bookmarks@4", "when": "scmProvider == oxygen" },
105 +
        { "command": "oxygen.tags", "group": "6_more@1", "when": "scmProvider == oxygen" },
106 +
        { "command": "oxygen.createTag", "group": "6_more@2", "when": "scmProvider == oxygen" },
107 +
        { "command": "oxygen.addWorkspace", "group": "6_more@3", "when": "scmProvider == oxygen" },
108 +
        { "command": "oxygen.updateSubrepos", "group": "6_more@4", "when": "scmProvider == oxygen" },
109 +
        { "command": "oxygen.verify", "group": "6_more@5", "when": "scmProvider == oxygen" },
87 110
        { "command": "oxygen.commitHunks", "group": "2_commit@1", "when": "scmProvider == oxygen" },
88 111
        { "command": "oxygen.amend", "group": "2_commit@2", "when": "scmProvider == oxygen" },
89 112
        { "command": "oxygen.undo", "group": "3_undo", "when": "scmProvider == oxygen" },
…
20 unchanged lines
110 133
        { "command": "oxygen.commitSelected", "group": "1_modification@1", "when": "scmProvider == oxygen" },
111 134
        { "command": "oxygen.discard", "group": "1_modification@2", "when": "scmProvider == oxygen" },
112 135
        { "command": "oxygen.keepCandidate", "group": "inline@0", "when": "scmProvider == oxygen && scmResourceState == candidate" },
136 +
        { "command": "oxygen.updateSubrepo", "group": "inline@0", "when": "scmProvider == oxygen && scmResourceState == subrepo" },
113 137
        { "command": "oxygen.keepCandidate", "group": "0_conflict", "when": "scmProvider == oxygen && scmResourceState == candidate" }
114 138
      ],
115 139
      "scm/resourceGroup/context": [
116 140
        { "command": "oxygen.markResolved", "group": "inline", "when": "scmProvider == oxygen && scmResourceGroup == conflicts" }
117 141
      ],
118 142
      "view/title": [
119 -
        { "command": "oxygen.refreshOperations", "group": "navigation", "when": "view == oxygen.operations" }
143 +
        { "command": "oxygen.refreshOperations", "group": "navigation", "when": "view == oxygen.operations" },
144 +
        { "command": "oxygen.refreshBisect", "group": "navigation", "when": "view == oxygen.bisect" },
145 +
        { "command": "oxygen.refreshTrust", "group": "navigation", "when": "view == oxygen.trust" }
120 146
      ],
121 147
      "view/item/context": [
122 148
        { "command": "oxygen.undoTo", "group": "inline", "when": "view == oxygen.operations && viewItem == operation" }
…
9 unchanged lines
132 158
        { "command": "oxygen.showChange", "when": "false" },
133 159
        { "command": "oxygen.acceptSides", "when": "false" },
134 160
        { "command": "oxygen.keepCandidate", "when": "false" },
135 -
        { "command": "oxygen.openConflict", "when": "false" }
161 +
        { "command": "oxygen.openConflict", "when": "false" },
162 +
        { "command": "oxygen.openSubrepo", "when": "false" },
163 +
        { "command": "oxygen.updateSubrepo", "when": "false" },
164 +
        { "command": "oxygen.refreshBisect", "when": "false" },
165 +
        { "command": "oxygen.refreshTrust", "when": "false" }
136 166
      ]
137 167
    },
138 168
    "configuration": {
…
57 unchanged lines
modified src/extension.ts +232 −1
…
3 unchanged lines
4 4
import * as vscode from "vscode";
5 5
import { basename } from "node:path";
6 6
import { Oxy, OxyError, findBinary } from "./oxy";
7 -
import { Repository, OxyResourceState } from "./repository";
7 +
import { Repository, OxyResourceState, SubrepoState } from "./repository";
8 +
import { BisectProvider, TrustProvider } from "./panels";
8 9
import { OxyContentProvider, SCHEME, baseUri, oxyUri } from "./contentProvider";
9 10
import { OxyDecorationProvider } from "./decorations";
10 11
import { OperationItem, OperationsProvider, timeAgo } from "./operations";
…
10 unchanged lines
21 22
let operations: OperationsProvider;
22 23
let blame: BlameController;
23 24
let conflictLenses: ConflictCodeLensProvider;
25 +
let bisectView: BisectProvider;
26 +
let trustView: TrustProvider;
24 27
const repositories = new Map<string, Repository>();
25 28
26 29
export async function activate(context: vscode.ExtensionContext): Promise<void> {
…
26 unchanged lines
53 56
  context.subscriptions.push(blame);
54 57
  conflictLenses = new ConflictCodeLensProvider(() => [...repositories.values()]);
55 58
  context.subscriptions.push(vscode.languages.registerCodeLensProvider({ scheme: "file" }, conflictLenses));
59 +
  bisectView = new BisectProvider(() => [...repositories.values()]);
60 +
  trustView = new TrustProvider(() => [...repositories.values()]);
61 +
  context.subscriptions.push(vscode.window.registerTreeDataProvider("oxygen.bisect", bisectView), vscode.window.registerTreeDataProvider("oxygen.trust", trustView));
56 62
57 63
  context.subscriptions.push(
58 64
    vscode.commands.registerCommand("oxygen.refresh", async () => {
…
38 unchanged lines
97 103
      return repo && markResolved(repo).catch(showError);
98 104
    }),
99 105
    vscode.commands.registerCommand("oxygen.openConflict", (state?: OxyResourceState) => state && vscode.commands.executeCommand("vscode.open", state.resourceUri)),
106 +
    vscode.commands.registerCommand("oxygen.openSubrepo", (state?: SubrepoState) => state && openSubrepo(state)),
107 +
    vscode.commands.registerCommand("oxygen.updateSubrepo", (state?: SubrepoState) => state && updateSubrepos(state.repository, true, [state.info.path])),
108 +
    vscode.commands.registerCommand("oxygen.updateSubrepos", (arg?: Repository | vscode.SourceControl) => {
109 +
      const repo = repositoryFor(arg);
110 +
      return repo && updateSubrepos(repo, true, []);
111 +
    }),
112 +
    vscode.commands.registerCommand("oxygen.bisectStart", (arg?: Repository) => bisect(arg, (r) => r.oxy.bisectStart(r.root).then(() => "Started; mark a good and a bad change"))),
113 +
    vscode.commands.registerCommand("oxygen.bisectGood", (arg?: Repository, changes?: string[]) => bisectMark(arg, "good", changes)),
114 +
    vscode.commands.registerCommand("oxygen.bisectBad", (arg?: Repository, changes?: string[]) => bisectMark(arg, "bad", changes)),
115 +
    vscode.commands.registerCommand("oxygen.bisectSkip", (arg?: Repository) => bisect(arg, (r) => r.oxy.bisectMark(r.root, "skip"))),
116 +
    vscode.commands.registerCommand("oxygen.bisectRun", (arg?: Repository) => bisectRun(arg)),
117 +
    vscode.commands.registerCommand("oxygen.bisectReset", (arg?: Repository) => bisect(arg, (r) => r.oxy.bisectReset(r.root).then(() => "Bisect reset; working copy restored"))),
118 +
    vscode.commands.registerCommand("oxygen.refreshBisect", () => bisectView.refresh()),
119 +
    vscode.commands.registerCommand("oxygen.refreshTrust", () => trustView.refresh()),
120 +
    vscode.commands.registerCommand("oxygen.verify", (arg?: Repository | vscode.SourceControl) => verify(arg)),
121 +
    vscode.commands.registerCommand("oxygen.tags", (arg?: Repository | vscode.SourceControl) => tags(arg)),
122 +
    vscode.commands.registerCommand("oxygen.createTag", (arg?: Repository | vscode.SourceControl) => createTag(arg)),
123 +
    vscode.commands.registerCommand("oxygen.addWorkspace", (arg?: Repository | vscode.SourceControl) => addWorkspace(arg)),
124 +
    vscode.commands.registerCommand("oxygen.clone", () => cloneRepository()),
100 125
    vscode.workspace.onDidChangeWorkspaceFolders(() => void discover()),
101 126
    vscode.workspace.onDidChangeConfiguration((e) => {
102 127
      if (e.affectsConfiguration("oxygen.path")) {
…
47 unchanged lines
150 175
        blame.invalidate(root);
151 176
        HistoryPanel.refresh(root);
152 177
        conflictLenses.refresh();
178 +
        bisectView.refresh();
179 +
        trustView.refresh();
153 180
        // The commit action reads as a resolution while conflicts are resolved but uncommitted.
154 181
        const resolved = status.conflicted && status.conflicts.length > 0 && status.conflicts.every((c) => c.resolved === true);
155 182
        repo.sourceControl.acceptInputCommand = { command: "oxygen.commit", title: resolved ? "Commit resolution" : "Commit", arguments: [repo] };
…
194 unchanged lines
350 377
  }
351 378
}
352 379
380 +
// ---------------------------------------------------------------- subrepos, bisect, trust, tags, workspaces, clone
381 +
382 +
async function openSubrepo(state: SubrepoState): Promise<void> {
383 +
  if (!state.info.actual) {
384 +
    const pick = await vscode.window.showInformationMessage(`${state.info.path} is not checked out yet.`, "Update --init");
385 +
    if (pick) {
386 +
      await updateSubrepos(state.repository, true, [state.info.path]);
387 +
    }
388 +
    return;
389 +
  }
390 +
  const pick = await vscode.window.showQuickPick(
391 +
    [
392 +
      { label: "$(folder-opened) Open in new window", action: "window" },
393 +
      { label: "$(cloud-download) Update to the pin", action: "update", description: state.info.at_pin ? "already at pin" : "moves the nested repository" },
394 +
    ],
395 +
    { placeHolder: `${state.info.path} · ${state.decorations.tooltip}` },
396 +
  );
397 +
  if (pick?.action === "window") {
398 +
    await vscode.commands.executeCommand("vscode.openFolder", state.resourceUri, { forceNewWindow: true });
399 +
  } else if (pick?.action === "update") {
400 +
    await updateSubrepos(state.repository, true, [state.info.path]);
401 +
  }
402 +
}
403 +
404 +
async function updateSubrepos(repo: Repository, init: boolean, paths: string[]): Promise<void> {
405 +
  try {
406 +
    const out = await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Updating subrepos" }, () => repo.oxy.submoduleUpdate(repo.root, init, paths));
407 +
    await repo.refresh();
408 +
    const lines = out.trim().split("\n").filter(Boolean);
409 +
    if (lines.some((l) => /missing|uncommitted|✖|x /.test(l))) {
410 +
      void vscode.window.showWarningMessage(`Oxygen: ${lines.join("; ")}`);
411 +
    } else {
412 +
      vscode.window.setStatusBarMessage(`$(check) ${lines.join("; ") || "Subrepos up to date"}`, 5000);
413 +
    }
414 +
  } catch (e) {
415 +
    showError(e);
416 +
  }
417 +
}
418 +
419 +
async function bisect(arg: Repository | undefined, run: (repo: Repository) => Promise<string>): Promise<void> {
420 +
  const repo = arg instanceof Repository ? arg : repositoryFor(undefined);
421 +
  if (!repo) {
422 +
    return;
423 +
  }
424 +
  try {
425 +
    const out = await run(repo);
426 +
    await repo.refresh();
427 +
    vscode.window.setStatusBarMessage(`$(search) ${out.split("\n")[0]}`, 5000);
428 +
  } catch (e) {
429 +
    showError(e);
430 +
  }
431 +
}
432 +
433 +
async function bisectMark(arg: Repository | undefined, verdict: "good" | "bad", changes?: string[]): Promise<void> {
434 +
  const repo = arg instanceof Repository ? arg : repositoryFor(undefined);
435 +
  if (!repo) {
436 +
    return;
437 +
  }
438 +
  let picked = changes;
439 +
  if (picked === undefined) {
440 +
    const status = await repo.oxy.bisectStatus(repo.root).catch(() => null);
441 +
    if (!status || status.state === "need-marks") {
442 +
      const value = await vscode.window.showInputBox({ prompt: `A change, bookmark or tag known to be ${verdict}`, placeHolder: verdict === "good" ? "v1.2 or a ChangeId prefix" : "main" });
443 +
      if (!value?.trim()) {
444 +
        return;
445 +
      }
446 +
      picked = value.trim().split(/\s+/);
447 +
    } else {
448 +
      picked = [];
449 +
    }
450 +
  }
451 +
  await bisect(repo, (r) => r.oxy.bisectMark(r.root, verdict, picked));
452 +
}
453 +
454 +
async function bisectRun(arg: Repository | undefined): Promise<void> {
455 +
  const repo = arg instanceof Repository ? arg : repositoryFor(undefined);
456 +
  if (!repo) {
457 +
    return;
458 +
  }
459 +
  const value = await vscode.window.showInputBox({ prompt: "Command to run for each step (exit 0 good, 125 skip, anything else bad)", placeHolder: "make test" });
460 +
  if (!value?.trim()) {
461 +
    return;
462 +
  }
463 +
  await bisect(repo, (r) => Promise.resolve(vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: "Oxygen: bisecting", cancellable: false }, () => r.oxy.bisectRun(r.root, ["sh", "-c", value.trim()]))));
464 +
}
465 +
466 +
async function verify(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
467 +
  const repo = repositoryFor(arg);
468 +
  if (!repo) {
469 +
    return;
470 +
  }
471 +
  try {
472 +
    const results = await repo.oxy.verify(repo.root);
473 +
    const icon = (s: string, revoked: boolean) => (s === "invalid" ? "$(error)" : s === "unsigned" ? "$(circle-outline)" : revoked ? "$(warning)" : "$(verified)");
474 +
    const pick = await vscode.window.showQuickPick(
475 +
      results.map((r) => ({
476 +
        label: `${icon(r.status, r.revoked)} ${r.change_id.slice(0, 8)} ${r.status}${r.revoked ? " (key revoked)" : ""}`,
477 +
        description: r.name ?? "",
478 +
        detail: r.reason ?? undefined,
479 +
        hash: r.hash,
480 +
      })),
481 +
      { placeHolder: `${results.length} changes; ${results.filter((r) => r.status === "invalid").length} invalid`, matchOnDescription: true },
482 +
    );
483 +
    if (pick) {
484 +
      await showChange(repo.root, pick.hash);
485 +
    }
486 +
  } catch (e) {
487 +
    showError(e);
488 +
  }
489 +
}
490 +
491 +
async function tags(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
492 +
  const repo = repositoryFor(arg);
493 +
  if (!repo) {
494 +
    return;
495 +
  }
496 +
  try {
497 +
    const list = await repo.oxy.tags(repo.root);
498 +
    if (list.length === 0) {
499 +
      const pick = await vscode.window.showInformationMessage("Oxygen: no tags yet.", "Create tag");
500 +
      if (pick) {
501 +
        await createTag(repo);
502 +
      }
503 +
      return;
504 +
    }
505 +
    const pick = await vscode.window.showQuickPick(
506 +
      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 })),
507 +
      { placeHolder: "Check out a tag's state" },
508 +
    );
509 +
    if (!pick) {
510 +
      return;
511 +
    }
512 +
    await repo.oxy.checkout(repo.root, [pick.tag.name]);
513 +
    await repo.refresh();
514 +
  } catch (e) {
515 +
    if (e instanceof OxyError && e.message.includes("has changes")) {
516 +
      void vscode.window.showWarningMessage("Oxygen: commit or discard your changes before checking out a tag.");
517 +
      return;
518 +
    }
519 +
    showError(e);
520 +
  }
521 +
}
522 +
523 +
async function createTag(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
524 +
  const repo = repositoryFor(arg);
525 +
  if (!repo) {
526 +
    return;
527 +
  }
528 +
  const name = await vscode.window.showInputBox({ prompt: "Tag name (tags the current bookmark's heads)" });
529 +
  if (!name?.trim()) {
530 +
    return;
531 +
  }
532 +
  const message = (await vscode.window.showInputBox({ prompt: "Tag message (optional)" })) ?? "";
533 +
  try {
534 +
    await repo.oxy.tagCreate(repo.root, name.trim(), [], message);
535 +
    await repo.refresh();
536 +
    vscode.window.setStatusBarMessage(`$(tag) Tagged ${name.trim()}`, 4000);
537 +
  } catch (e) {
538 +
    showError(e);
539 +
  }
540 +
}
541 +
542 +
async function addWorkspace(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
543 +
  const repo = repositoryFor(arg);
544 +
  if (!repo) {
545 +
    return;
546 +
  }
547 +
  const folders = await vscode.window.showOpenDialog({ canSelectFiles: false, canSelectFolders: true, canSelectMany: false, openLabel: "Create workspace here", title: "Folder for the new workspace (must be empty or absent)" });
548 +
  if (!folders?.[0]) {
549 +
    return;
550 +
  }
551 +
  try {
552 +
    await repo.oxy.workspaceAdd(repo.root, folders[0].fsPath);
553 +
    const pick = await vscode.window.showInformationMessage(`Oxygen: workspace created at ${folders[0].fsPath}.`, "Open in new window");
554 +
    if (pick) {
555 +
      await vscode.commands.executeCommand("vscode.openFolder", folders[0], { forceNewWindow: true });
556 +
    }
557 +
  } catch (e) {
558 +
    showError(e);
559 +
  }
560 +
}
561 +
562 +
async function cloneRepository(): Promise<void> {
563 +
  const url = await vscode.window.showInputBox({ prompt: "Repository to clone (path or https URL)", placeHolder: "https://host:8890/project" });
564 +
  if (!url?.trim()) {
565 +
    return;
566 +
  }
567 +
  const parent = await vscode.window.showOpenDialog({ canSelectFiles: false, canSelectFolders: true, canSelectMany: false, openLabel: "Clone into this folder" });
568 +
  if (!parent?.[0]) {
569 +
    return;
570 +
  }
571 +
  const name = url.trim().replace(/\/+$/, "").split("/").pop()?.replace(/\.oxy$/, "") || "repository";
572 +
  const dest = vscode.Uri.joinPath(parent[0], name);
573 +
  try {
574 +
    await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: `Oxygen: cloning ${name}` }, () => oxy.clone(parent[0].fsPath, url.trim(), dest.fsPath));
575 +
    const pick = await vscode.window.showInformationMessage(`Oxygen: cloned into ${dest.fsPath}.`, "Open", "Open in new window");
576 +
    if (pick) {
577 +
      await vscode.commands.executeCommand("vscode.openFolder", dest, { forceNewWindow: pick === "Open in new window" });
578 +
    }
579 +
  } catch (e) {
580 +
    showError(e);
581 +
  }
582 +
}
583 +
353 584
// ---------------------------------------------------------------- history
354 585
355 586
async function showChange(root: string, hash: string): Promise<void> {
…
444 unchanged lines
modified src/oxy.ts +103 −0
…
253 unchanged lines
254 254
  tagCreate(root: string, name: string, changes: string[] = [], message = ""): Promise<void> {
255 255
    return this.check(root, ["tag", "create", name, ...changes, "-m", message]).then(() => undefined);
256 256
  }
257 +
258 +
  // ---------------------------------------------------------------- subrepos, bisect, trust, tags, workspaces
259 +
260 +
  submodules(root: string): Promise<SubrepoInfo[]> {
261 +
    return this.json<SubrepoInfo[]>(root, ["submodule", "status"]);
262 +
  }
263 +
264 +
  submoduleUpdate(root: string, init: boolean, paths: string[] = []): Promise<string> {
265 +
    return this.check(root, ["submodule", "update", ...(init ? ["--init"] : []), ...paths], [0, 1]).then((r) => r.stdout.toString("utf8"));
266 +
  }
267 +
268 +
  bisectStatus(root: string): Promise<BisectStatus | null> {
269 +
    return this.json<BisectStatus | null>(root, ["bisect", "status"]);
270 +
  }
271 +
272 +
  bisectStart(root: string): Promise<void> {
273 +
    return this.check(root, ["bisect", "start"]).then(() => undefined);
274 +
  }
275 +
276 +
  bisectMark(root: string, verdict: "good" | "bad" | "skip", changes: string[] = []): Promise<string> {
277 +
    return this.check(root, ["bisect", verdict, ...changes]).then((r) => r.stdout.toString("utf8").trim());
278 +
  }
279 +
280 +
  bisectRun(root: string, command: string[]): Promise<string> {
281 +
    return this.check(root, ["bisect", "run", ...command], [0, 1]).then((r) => r.stdout.toString("utf8").trim());
282 +
  }
283 +
284 +
  bisectReset(root: string): Promise<void> {
285 +
    return this.check(root, ["bisect", "reset"]).then(() => undefined);
286 +
  }
287 +
288 +
  policyShow(root: string): Promise<PolicyInfo | null> {
289 +
    return this.json<PolicyInfo | null>(root, ["policy", "show"]);
290 +
  }
291 +
292 +
  verify(root: string, change?: string): Promise<VerifyInfo[]> {
293 +
    return this.json<VerifyInfo[]>(root, ["verify", ...(change ? [change] : [])], [0, 1]);
294 +
  }
295 +
296 +
  tags(root: string): Promise<TagInfo[]> {
297 +
    return this.json<TagInfo[]>(root, ["tag", "list"]);
298 +
  }
299 +
300 +
  workspaces(root: string): Promise<WorkspaceInfo[]> {
301 +
    return this.json<WorkspaceInfo[]>(root, ["workspace", "list"]);
302 +
  }
303 +
304 +
  workspaceAdd(root: string, path: string, changes: string[] = []): Promise<void> {
305 +
    return this.check(root, ["workspace", "add", path, ...changes]).then(() => undefined);
306 +
  }
307 +
}
308 +
309 +
export interface SubrepoInfo {
310 +
  path: string;
311 +
  pin: PinInfo;
312 +
  actual: PinInfo | null;
313 +
  at_pin: boolean;
314 +
  dirty: boolean;
315 +
  url: string | null;
316 +
}
317 +
318 +
export type PinInfo = { kind: "oxy"; repo: string; heads: string[]; bookmark: string } | { kind: "git"; sha: string; branch: string };
319 +
320 +
export type BisectStatus =
321 +
  | { state: "need-marks"; good: number; bad: number }
322 +
  | { state: "testing"; heads: string[]; remaining: number; steps_left: number }
323 +
  | { state: "found"; culprit: string }
324 +
  | { state: "inconclusive"; candidates: string[] };
325 +
326 +
export interface PolicyInfo {
327 +
  hash: string;
328 +
  serial: number;
329 +
  expires_ms: number;
330 +
  expired: boolean;
331 +
  root_threshold: number;
332 +
  roots: { key: string; name: string; email: string }[];
333 +
  rules: { pattern: string; keys: { key: string; name: string; email: string }[]; threshold: number; require: string; allow_conflicts: boolean }[];
334 +
  revoked: { key: string; name: string; email: string }[];
335 +
}
336 +
337 +
export interface VerifyInfo {
338 +
  hash: string;
339 +
  change_id: string;
340 +
  status: string;
341 +
  key: string | null;
342 +
  name: string | null;
343 +
  revoked: boolean;
344 +
  reason: string | null;
345 +
}
346 +
347 +
export interface TagInfo {
348 +
  name: string;
349 +
  state: string;
350 +
  heads: string[];
351 +
  message: string;
352 +
  signer: string | null;
353 +
}
354 +
355 +
export interface WorkspaceInfo {
356 +
  id: string;
357 +
  path: string;
358 +
  current: boolean;
359 +
  working_copy: string | null;
257 360
}
258 361
259 362
export interface BlameLine {
…
115 unchanged lines
modified src/repository.ts +36 −3
…
3 unchanged lines
4 4
5 5
import * as vscode from "vscode";
6 6
import { basename, join, relative } from "node:path";
7 -
import { BookmarkInfo, Oxy, PathStatus, StatusInfo } from "./oxy";
7 +
import { BookmarkInfo, Oxy, PathStatus, StatusInfo, SubrepoInfo } from "./oxy";
8 8
import { baseUri } from "./contentProvider";
9 9
10 10
const STATUS_LETTER: Record<PathStatus, string> = {
…
57 unchanged lines
68 68
  }
69 69
}
70 70
71 +
export class SubrepoState implements vscode.SourceControlResourceState {
72 +
  readonly resourceUri: vscode.Uri;
73 +
  readonly decorations: vscode.SourceControlResourceDecorations;
74 +
  readonly command: vscode.Command;
75 +
  readonly contextValue = "subrepo";
76 +
77 +
  constructor(
78 +
    readonly repository: Repository,
79 +
    readonly info: SubrepoInfo,
80 +
  ) {
81 +
    this.resourceUri = vscode.Uri.file(join(repository.root, info.path));
82 +
    const pin = info.pin.kind === "oxy" ? `${info.pin.bookmark} ${info.pin.heads.map((h) => h.slice(0, 8)).join(" ")}` : `git ${info.pin.branch} ${info.pin.sha.slice(0, 10)}`;
83 +
    const state = !info.actual ? "missing (update --init)" : info.dirty ? "uncommitted changes inside" : info.at_pin ? "at pin" : "differs from pin";
84 +
    this.decorations = { tooltip: `${pin} · ${state}`, faded: !info.actual, strikeThrough: false };
85 +
    this.command = { command: "oxygen.openSubrepo", title: "Open", arguments: [this] };
86 +
  }
87 +
}
88 +
71 89
export class Repository implements vscode.Disposable, vscode.QuickDiffProvider {
72 90
  readonly sourceControl: vscode.SourceControl;
73 91
  readonly conflictsGroup: vscode.SourceControlResourceGroup;
74 92
  readonly changesGroup: vscode.SourceControlResourceGroup;
93 +
  readonly subreposGroup: vscode.SourceControlResourceGroup;
94 +
  private subreposLatest: SubrepoInfo[] = [];
75 95
  private readonly statusBar: vscode.StatusBarItem;
76 96
  private readonly syncBar: vscode.StatusBarItem;
77 97
  private bookmarksLatest: BookmarkInfo[] = [];
…
18 unchanged lines
96 116
    this.conflictsGroup = this.sourceControl.createResourceGroup("conflicts", "Conflicts");
97 117
    this.conflictsGroup.hideWhenEmpty = true;
98 118
    this.changesGroup = this.sourceControl.createResourceGroup("changes", "Changes");
119 +
    this.subreposGroup = this.sourceControl.createResourceGroup("subrepos", "Subrepos");
120 +
    this.subreposGroup.hideWhenEmpty = true;
99 121
    this.statusBar = vscode.window.createStatusBarItem("oxygen.status", vscode.StatusBarAlignment.Left, 100);
100 122
    this.statusBar.name = "Oxygen";
101 123
    this.statusBar.command = { command: "oxygen.switchBookmark", title: "Switch bookmark", arguments: [this] };
102 124
    this.syncBar = vscode.window.createStatusBarItem("oxygen.sync", vscode.StatusBarAlignment.Left, 99);
103 125
    this.syncBar.name = "Oxygen sync";
104 126
    this.syncBar.command = { command: "oxygen.sync", title: "Sync", arguments: [this] };
105 -
    this.disposables.push(this.sourceControl, this.conflictsGroup, this.changesGroup, this.statusBar, this.syncBar, this.onDidChangeStatusEmitter);
127 +
    this.disposables.push(this.sourceControl, this.conflictsGroup, this.changesGroup, this.subreposGroup, this.statusBar, this.syncBar, this.onDidChangeStatusEmitter);
106 128
107 129
    if (vscode.workspace.getConfiguration("oxygen").get<boolean>("autoRefresh", true)) {
108 130
      // Files in the workspace, except the repository's own store.
…
24 unchanged lines
133 155
    return this.bookmarksLatest;
134 156
  }
135 157
158 +
  get subrepos(): SubrepoInfo[] {
159 +
    return this.subreposLatest;
160 +
  }
161 +
136 162
  get currentBookmark(): BookmarkInfo | undefined {
137 163
    return this.bookmarksLatest.find((b) => b.current);
138 164
  }
…
35 unchanged lines
174 200
175 201
  private async doRefresh(): Promise<void> {
176 202
    try {
177 -
      const [status, prompt, bookmarks] = await Promise.all([this.oxy.status(this.root), this.oxy.prompt(this.root), this.oxy.bookmarks(this.root).catch(() => [] as BookmarkInfo[])]);
203 +
      const [status, prompt, bookmarks, subrepos] = await Promise.all([
204 +
        this.oxy.status(this.root),
205 +
        this.oxy.prompt(this.root),
206 +
        this.oxy.bookmarks(this.root).catch(() => [] as BookmarkInfo[]),
207 +
        this.oxy.submodules(this.root).catch(() => [] as SubrepoInfo[]),
208 +
      ]);
178 209
      this.latest = status;
179 210
      this.bookmarksLatest = bookmarks;
211 +
      this.subreposLatest = subrepos;
212 +
      this.subreposGroup.resourceStates = subrepos.map((s) => new SubrepoState(this, s));
180 213
      const conflicted = new Set(status.conflicts.map((c) => c.path).filter((p): p is string => !!p));
181 214
      const conflicts: OxyResourceState[] = [];
182 215
      const changes: OxyResourceState[] = [];
…
76 unchanged lines
modified test/oxy.test.mjs +16 −0
…
122 unchanged lines
123 123
  assert.match(await oxy.diffText(root, `#${blame[1].hash.slice(0, 12)}`), /\+feature/);
124 124
  const history = await oxy.log(root, 50, "a.txt");
125 125
  assert.equal(history.changes.filter((c) => !c.working_copy).length, 2);
126 +
127 +
  // Tags, workspaces, trust, bisect status, subrepos.
128 +
  await oxy.tagCreate(root, "v1", [], "first");
129 +
  const tags = await oxy.tags(root);
130 +
  assert.deepEqual(tags.map((t) => [t.name, t.message]), [["v1", "first"]]);
131 +
  const workspaces = await oxy.workspaces(root);
132 +
  assert.equal(workspaces.length, 1);
133 +
  assert.equal(workspaces[0].current, true);
134 +
  assert.equal(await oxy.policyShow(root), null, "no policy yet");
135 +
  const verify = await oxy.verify(root);
136 +
  assert.ok(verify.every((v) => v.status === "unsigned"));
137 +
  assert.equal(await oxy.bisectStatus(root), null);
138 +
  assert.deepEqual(await oxy.submodules(root), []);
139 +
  await oxy.bisectStart(root);
140 +
  assert.equal((await oxy.bisectStatus(root))?.state, "need-marks");
141 +
  await oxy.bisectReset(root);
126 142
});
127 143