OXYGENHUB

History graph: split, squash and rebase from the change picker or a right-click menu, confirmed first and undoable; 0.8.0 b4626290e660

closes #1
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 16:03 UTC
Signature
✔ signed by Andy McCall
Hash
b4626290e6607a36264acd6feffe828fa0b0a727b1abc189f904b8cafed86d4a
Depends on
35d56b5803d9 9020cf612749 9627171e23e2 ab81294a9ba9 bed495a96408 c8f955891224 d2c9a5843004 f6bda77ecb9f
Depended on by
dac6876e4d08 013fefef11e3 cdcffe39dd9f 1f7fc1e3ae4a 44bfd5adfc9a
In bookmarks
main
In tags
v0.16.0 v0.16.1 v0.17.0 v0.17.1 v0.18.0
Edits
20 atoms, 0 tree operations, 7 files

Browse the tree at this change

modified CHANGELOG.md +8 −0
1 1
# Changelog
2 2
3 +
## 0.8.0
4 +
5 +
- The History graph rewrites history:
6 +
  - Split a head: the files you pick become a separate first change.
7 +
  - Squash a change into one of its dependencies.
8 +
  - Rebase a change onto a bookmark, change or tag.
9 +
- Use them from the change picker or by right-clicking a row. Each asks first, and afterwards offers Undo.
10 +
3 11
## 0.7.1
4 12
5 13
- Messages write the forge's name as OxygenHub.
…
41 unchanged lines
modified README.md +2 −1
…
39 unchanged lines
40 40
- **Views**: Oxygen Operations (undo to any point), Oxygen Bisect
41 41
  (start, good, bad, skip, run, reset), Oxygen Trust (policy, signatures),
42 42
  and a History graph with per-change actions (diff, check out, describe,
43 -
  tag, abandon).
43 +
  tag, abandon, and split, squash and rebase from the picker or by
44 +
  right-clicking a row, each confirmed and undoable).
44 45
- **Commands**: Show History, File History, Tags, Create Tag, Add Workspace,
45 46
  Update Subrepos, Verify Signatures, Clone Repository, Initialize Repository,
46 47
  Show Output.
…
10 unchanged lines
modified package.json +48 −1
…
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.7.1",
5 +
  "version": "0.8.0",
6 6
  "publisher": "oxygen",
7 7
  "license": "MIT OR Apache-2.0",
8 8
  "icon": "assets/images/oxygen.png",
…
283 unchanged lines
292 292
        "command": "oxygen.openChangeOnRemote",
293 293
        "title": "Open Change on Remote",
294 294
        "category": "Oxygen"
295 +
      },
296 +
      {
297 +
        "command": "oxygen.history.split",
298 +
        "title": "Split\u2026",
299 +
        "category": "Oxygen",
300 +
        "icon": "$(split-horizontal)"
301 +
      },
302 +
      {
303 +
        "command": "oxygen.history.squash",
304 +
        "title": "Squash into a Dependency\u2026",
305 +
        "category": "Oxygen",
306 +
        "icon": "$(fold)"
307 +
      },
308 +
      {
309 +
        "command": "oxygen.history.rebase",
310 +
        "title": "Rebase onto\u2026",
311 +
        "category": "Oxygen",
312 +
        "icon": "$(git-pull-request-go-to-changes)"
295 313
      }
296 314
    ],
297 315
    "views": {
…
314 unchanged lines
612 630
        },
613 631
        {
614 632
          "command": "oxygen.refreshTrust",
633 +
          "when": "false"
634 +
        },
635 +
        {
636 +
          "command": "oxygen.history.split",
615 637
          "when": "false"
638 +
        },
639 +
        {
640 +
          "command": "oxygen.history.squash",
641 +
          "when": "false"
642 +
        },
643 +
        {
644 +
          "command": "oxygen.history.rebase",
645 +
          "when": "false"
616 646
        }
617 647
      ],
618 648
      "editor/title/context": [
…
1 unchanged lines
620 650
          "command": "oxygen.openOnRemote",
621 651
          "when": "oxygen.hasRemoteProvider",
622 652
          "group": "oxygen"
653 +
        }
654 +
      ],
655 +
      "webview/context": [
656 +
        {
657 +
          "command": "oxygen.history.split",
658 +
          "when": "webviewId == 'oxygen.history' && webviewSection == 'change' && oxygenHead",
659 +
          "group": "oxygen@1"
660 +
        },
661 +
        {
662 +
          "command": "oxygen.history.squash",
663 +
          "when": "webviewId == 'oxygen.history' && webviewSection == 'change' && oxygenHasDeps",
664 +
          "group": "oxygen@2"
665 +
        },
666 +
        {
667 +
          "command": "oxygen.history.rebase",
668 +
          "when": "webviewId == 'oxygen.history' && webviewSection == 'change'",
669 +
          "group": "oxygen@3"
623 670
        }
624 671
      ]
625 672
    },
…
58 unchanged lines
modified src/extension.ts +124 −1
…
3 unchanged lines
4 4
import * as vscode from "vscode";
5 5
import { OxygenAPI, RemoteProvider, RemoteTarget, RepositoryHandle, pickProvider, repositoryFor as rootFor } from "./api";
6 6
import { basename } from "node:path";
7 -
import { Oxy, OxyError, findBinary } from "./oxy";
7 +
import { Oxy, OxyError, ShowInfo, findBinary } 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";
…
67 unchanged lines
78 78
    vscode.commands.registerCommand("oxygen.commitSelected", (state?: OxyResourceState, states?: OxyResourceState[]) => commitSelected(states?.length ? states : state ? [state] : [])),
79 79
    vscode.commands.registerCommand("oxygen.commitHunks", (arg?: Repository | vscode.SourceControl) => commitHunks(arg)),
80 80
    vscode.commands.registerCommand("oxygen.amend", (arg?: Repository | vscode.SourceControl) => amend(arg)),
81 +
    vscode.commands.registerCommand("oxygen.history.split", (ctx?: { root?: string; hash?: string }) => historyContext(ctx, "split")),
82 +
    vscode.commands.registerCommand("oxygen.history.squash", (ctx?: { root?: string; hash?: string }) => historyContext(ctx, "squash")),
83 +
    vscode.commands.registerCommand("oxygen.history.rebase", (ctx?: { root?: string; hash?: string }) => historyContext(ctx, "rebase")),
81 84
    vscode.commands.registerCommand("oxygen.undo", (arg?: Repository | vscode.SourceControl) => undo(arg, 1)),
82 85
    vscode.commands.registerCommand("oxygen.undoTo", (item?: OperationItem) => item && undo(item.repository, item.stepsBack)),
83 86
    vscode.commands.registerCommand("oxygen.refreshOperations", () => operations.refresh()),
…
613 unchanged lines
697 700
    { label: "$(edit) Describe…", description: "change the message", action: "describe" },
698 701
    { label: "$(tag) Tag…", action: "tag" },
699 702
    { label: "$(trash) Abandon", description: "drop the change; its content stays on disk if the working copy sat on it", action: "abandon" },
703 +
    ...historyItems(info),
700 704
    { label: "$(copy) Copy ChangeId", action: "copy" },
701 705
    ...info.files.map((f) => ({ label: `$(file) ${f.path}`, description: `${f.kind}${f.from ? ` from ${f.from}` : ""}`, action: "file", path: f.path })),
702 706
  ];
…
45 unchanged lines
748 752
      }
749 753
      case "copy":
750 754
        await vscode.env.clipboard.writeText(info.change_id);
755 +
        return;
756 +
      case "split":
757 +
      case "squash":
758 +
      case "rebase":
759 +
        await historyAction(repo, info, pick.action);
751 760
        return;
752 761
    }
753 762
    await repo.refresh();
…
10 unchanged lines
764 773
      }
765 774
      return;
766 775
    }
776 +
    showError(e);
777 +
  }
778 +
}
779 +
780 +
// ------------------------------------------------ history rewriting (V6)
781 +
782 +
type HistoryAction = "split" | "squash" | "rebase";
783 +
784 +
/** The rewriting actions a change offers: split for a head with several files, squash when it has dependencies, rebase always. */
785 +
function historyItems(info: ShowInfo): (vscode.QuickPickItem & { action: string })[] {
786 +
  const items: (vscode.QuickPickItem & { action: string })[] = [];
787 +
  if (info.head && info.files.length > 1) {
788 +
    items.push({ label: "$(split-horizontal) Split…", description: "chosen files become a separate first change", action: "split" });
789 +
  }
790 +
  if (info.deps.length > 0) {
791 +
    items.push({ label: "$(fold) Squash into a dependency…", description: "fold this change into one it depends on", action: "squash" });
792 +
  }
793 +
  items.push({ label: "$(git-pull-request-go-to-changes) Rebase onto…", description: "re-express against another state", action: "rebase" });
794 +
  return items;
795 +
}
796 +
797 +
/** Run `action` on a change: choose, confirm, run, then offer Undo (oxy undo reverses every rewrite). */
798 +
async function historyAction(repo: Repository, info: ShowInfo, action: HistoryAction): Promise<void> {
799 +
  const change = `#${info.hash.slice(0, 12)}`;
800 +
  const subject = info.message.split("\n")[0] || "(no description)";
801 +
  let run: () => Promise<string>;
802 +
  let question: string;
803 +
  if (action === "split") {
804 +
    if (!info.head) {
805 +
      void vscode.window.showInformationMessage("Oxygen: only a head can be split; nothing may depend on it yet.");
806 +
      return;
807 +
    }
808 +
    const picked = await vscode.window.showQuickPick(
809 +
      info.files.map((f) => ({ label: f.path, description: f.kind, picked: false })),
810 +
      { title: `Split ${info.change_id.slice(0, 8)}: the files you choose become the first change`, canPickMany: true },
811 +
    );
812 +
    if (!picked || picked.length === 0) {
813 +
      return;
814 +
    }
815 +
    if (picked.length === info.files.length) {
816 +
      void vscode.window.showInformationMessage("Oxygen: leave at least one file for the second change.");
817 +
      return;
818 +
    }
819 +
    const paths = picked.map((p) => p.label);
820 +
    question = `Split "${subject}"? ${paths.length} file${paths.length === 1 ? "" : "s"} (${paths.join(", ")}) become a separate change; the rest stay.`;
821 +
    run = () => repo.oxy.split(repo.root, change, paths);
822 +
  } else if (action === "squash") {
823 +
    if (info.deps.length === 0) {
824 +
      void vscode.window.showInformationMessage("Oxygen: this change has no dependency to fold into.");
825 +
      return;
826 +
    }
827 +
    const log = await repo.oxy.log(repo.root, 500);
828 +
    const byHash = new Map(log.changes.map((c) => [c.hash, c]));
829 +
    const choices = info.deps.map((d) => {
830 +
      const c = byHash.get(d);
831 +
      return { label: c ? c.message.split("\n")[0] || "(no description)" : d.slice(0, 12), description: c ? c.change_id.slice(0, 8) : "", hash: d };
832 +
    });
833 +
    const into = choices.length === 1 ? choices[0] : await vscode.window.showQuickPick(choices, { title: `Squash ${info.change_id.slice(0, 8)} into…` });
834 +
    if (!into) {
835 +
      return;
836 +
    }
837 +
    question = `Squash "${subject}" into "${into.label}"? The dependency keeps its ChangeId and absorbs these edits.`;
838 +
    run = () => repo.oxy.squash(repo.root, change, `#${into.hash.slice(0, 12)}`);
839 +
  } else {
840 +
    const bookmarks = await repo.oxy.bookmarks(repo.root);
841 +
    type Onto = vscode.QuickPickItem & { onto?: string };
842 +
    const choices: Onto[] = [
843 +
      ...bookmarks.map((b) => ({ label: `$(bookmark) ${b.name}`, description: b.current ? "current bookmark" : "", onto: b.name })),
844 +
      { label: "$(edit) Another change, bookmark or tag…" },
845 +
    ];
846 +
    const pick = await vscode.window.showQuickPick(choices, { title: `Rebase ${info.change_id.slice(0, 8)} onto…` });
847 +
    if (!pick) {
848 +
      return;
849 +
    }
850 +
    let onto = pick.onto;
851 +
    if (!onto) {
852 +
      onto = (await vscode.window.showInputBox({ prompt: "ChangeId prefix, #hash prefix, bookmark or tag" }))?.trim();
853 +
      if (!onto) {
854 +
        return;
855 +
      }
856 +
    }
857 +
    const target = onto;
858 +
    question = `Rebase "${subject}" onto ${target}? The change is re-expressed against that state; its ChangeId stays.`;
859 +
    run = () => repo.oxy.rebase(repo.root, change, [target]);
860 +
  }
861 +
  const verb = action[0].toUpperCase() + action.slice(1);
862 +
  if ((await vscode.window.showWarningMessage(question, { modal: true, detail: "Undo reverses it (Oxygen: Undo, or oxy undo)." }, verb)) !== verb) {
863 +
    return;
864 +
  }
865 +
  try {
866 +
    const out = await run();
867 +
    await repo.refresh();
868 +
    HistoryPanel.refresh(repo.root);
869 +
    const undo = await vscode.window.showInformationMessage(`Oxygen: ${out.split("\n")[0].replace(/^\* /, "")}`, "Undo");
870 +
    if (undo === "Undo") {
871 +
      await repo.oxy.undo(repo.root);
872 +
      await repo.refresh();
873 +
      HistoryPanel.refresh(repo.root);
874 +
    }
875 +
  } catch (e) {
876 +
    showError(e);
877 +
  }
878 +
}
879 +
880 +
/** A history-graph context menu command: the webview passes `{ root, hash }`. */
881 +
async function historyContext(ctx: { root?: string; hash?: string } | undefined, action: HistoryAction): Promise<void> {
882 +
  const repo = ctx?.root ? repositories.get(ctx.root) : undefined;
883 +
  if (!repo || !ctx?.hash) {
884 +
    return;
885 +
  }
886 +
  try {
887 +
    const info = await repo.oxy.show(repo.root, `#${ctx.hash.slice(0, 12)}`);
888 +
    await historyAction(repo, info, action);
889 +
  } catch (e) {
767 890
    showError(e);
768 891
  }
769 892
}
…
355 unchanged lines
modified src/history.ts +4 −2
…
126 unchanged lines
127 127
    .map((c, i) => {
128 128
      const subject = c.message.split("\n")[0] || (c.working_copy ? "(working copy)" : "(no description)");
129 129
      const sig = c.working_copy ? "" : c.signature?.status === "signed" || c.signature?.status === "sealed" ? (c.signature.revoked ? "⚠" : "✔") : c.signature?.status === "invalid" ? "✖" : "·";
130 -
      return `<div class="row${c.working_copy ? " wc" : ""}" style="top:${i * rowH}px" data-hash="${c.hash}" title="${escape(c.message)}">
130 +
      // Right-click: split, squash and rebase (webview/context menus in package.json).
131 +
      const ctx = c.working_copy ? "" : ` data-vscode-context='${escape(JSON.stringify({ webviewSection: "change", root: repo.root, hash: c.hash, oxygenHead: c.head, oxygenHasDeps: c.deps.length > 0, preventDefaultContextMenuItems: true }))}'`;
132 +
      return `<div class="row${c.working_copy ? " wc" : ""}" style="top:${i * rowH}px" data-hash="${c.hash}" title="${escape(c.message)}"${ctx}>
131 133
        <span class="id">${c.change_id.slice(0, 8)}</span>
132 134
        <span class="subject">${escape(subject)}</span>
133 135
        <span class="meta">${escape(c.author)} · ${timeAgo(c.when_ms)} <span class="sig" title="${escape(c.signature?.status ?? "")}">${sig}</span></span>
…
36 unchanged lines
170 172
}
171 173
172 174
function escape(s: string): string {
173 -
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
175 +
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
174 176
}
175 177
176 178
export function repositoryLabel(repo: Repository): string {
…
2 unchanged lines
modified src/oxy.ts +15 −0
…
246 unchanged lines
247 247
    return this.check(root, ["abandon", change]).then((r) => r.stdout.toString("utf8").trim());
248 248
  }
249 249
250 +
  /** Split a head: the edits to `paths` become a separate first change. Returns oxy's summary line. */
251 +
  split(root: string, change: string, paths: string[]): Promise<string> {
252 +
    return this.check(root, ["split", change, ...paths]).then((r) => r.stdout.toString("utf8").trim());
253 +
  }
254 +
255 +
  /** Fold `change` into its dependency `into`, which keeps its ChangeId. */
256 +
  squash(root: string, change: string, into: string): Promise<string> {
257 +
    return this.check(root, ["squash", change, "--into", into]).then((r) => r.stdout.toString("utf8").trim());
258 +
  }
259 +
260 +
  /** Re-express `change` against the state of `onto` (changes, a bookmark or a tag). */
261 +
  rebase(root: string, change: string, onto: string[]): Promise<string> {
262 +
    return this.check(root, ["rebase", change, "--onto", ...onto]).then((r) => r.stdout.toString("utf8").trim());
263 +
  }
264 +
250 265
  checkout(root: string, changes: string[], discard = false): Promise<void> {
251 266
    return this.check(root, ["checkout", ...changes, ...(discard ? ["--discard"] : [])]).then(() => undefined);
252 267
  }
…
225 unchanged lines
modified test/oxy.test.mjs +40 −0
…
140 unchanged lines
141 141
  await oxy.bisectReset(root);
142 142
});
143 143
144 +
145 +
test("split, squash and rebase through the history actions, each undoable", async () => {
146 +
  const dir = mkdtempSync(join(tmpdir(), "oxy-vscode-hist-"));
147 +
  const home = join(dir, "home");
148 +
  mkdirSync(home);
149 +
  process.env.OXY_HOME = home;
150 +
  await oxy.check(dir, ["init", "repo"]);
151 +
  const root = join(dir, "repo");
152 +
  await oxy.check(root, ["identity", "create", "--name", "Test", "--email", "[email protected]"]);
153 +
  writeFileSync(join(root, "a.txt"), "a\n");
154 +
  await oxy.commit(root, "one");
155 +
  writeFileSync(join(root, "b.txt"), "b\n");
156 +
  writeFileSync(join(root, "c.txt"), "c\n");
157 +
  await oxy.commit(root, "two files");
158 +
  const find = async (m) => (await oxy.log(root, 50)).changes.find((c) => !c.working_copy && c.message.startsWith(m));
159 +
160 +
  // Split: b.txt becomes its own change, c.txt stays.
161 +
  const two = await find("two files");
162 +
  const out = await oxy.split(root, `#${two.hash.slice(0, 12)}`, ["b.txt"]);
163 +
  assert.match(out, /Split into/);
164 +
  const part = await find("two files (part 1");
165 +
  assert.ok(part, "the first part exists");
166 +
  assert.deepEqual((await oxy.show(root, `#${part.hash.slice(0, 12)}`)).files.map((f) => f.path), ["b.txt"]);
167 +
  await oxy.undo(root);
168 +
  assert.equal(await find("two files (part 1"), undefined, "undo reverses the split");
169 +
170 +
  // Squash: an edit of a.txt folds into "one", which keeps its ChangeId.
171 +
  writeFileSync(join(root, "a.txt"), "a\na2\n");
172 +
  await oxy.commit(root, "edit a");
173 +
  const one = await find("one");
174 +
  const edit = await find("edit a");
175 +
  assert.deepEqual(edit.deps, [one.hash]);
176 +
  assert.match(await oxy.squash(root, `#${edit.hash.slice(0, 12)}`, `#${one.hash.slice(0, 12)}`), /Squashed into/);
177 +
  const after = await find("one");
178 +
  assert.equal(after.change_id, one.change_id);
179 +
  assert.equal(await find("edit a"), undefined);
180 +
181 +
  // Rebase onto a state that already has the change is refused, with oxy's reason.
182 +
  await assert.rejects(oxy.rebase(root, `#${after.hash.slice(0, 12)}`, ["main"]), /already part of the target state/);
183 +
});