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 | }); |