OXYGENHUB

Plan squashes and rebases by dragging on a proposal's swimlanes 2bfe5ef66b0c

On an editable proposal's board, dropping one change on another asks the
hub what the drop could mean. The answer is a squash and/or a rebase, each
with its oxy commands and a VS Code link that rewrites the changes where
the author's key is.

closes #36
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 07:11 UTC
Signature
✔ signed by Andy McCall
Hash
2bfe5ef66b0c50fe534d11cac8b8a51aa92daa248f27316df8c6e6c01e5f3dea
Depends on
5e6d6484c28e 736f6813543d 92574625ee9a 93be9ce4653e b64e2537d702 d23464c0ac60
Depended on by
c8684d44d549 cb71e18eb6f9 c96b32865000 129c23effb73 d3e75fb41e61 38833ed78707
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
10 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified assets/hub.js +42 −0
…
342 unchanged lines
343 343
      live.querySelectorAll("input, textarea, button").forEach(function (x) { x.disabled = true; });
344 344
    });
345 345
  }
346 +
347 +
  // Swimlanes of a proposal's bookmark (hub spec §6.7): drag a change onto
348 +
  // another to plan a squash or a rebase. The hub only writes the plan;
349 +
  // the author's client carries it out and signs it.
350 +
  var plan = document.querySelector("[data-plan]");
351 +
  if (plan && window.fetch) {
352 +
    var panel = document.querySelector("[data-plan-panel]"), from = null, moved = false;
353 +
    plan.querySelectorAll("[data-change]").forEach(function (a) {
354 +
      a.style.cursor = "grab";
355 +
      a.addEventListener("pointerdown", function (ev) { from = a.getAttribute("data-change"); moved = false; ev.preventDefault(); });
356 +
      a.addEventListener("pointermove", function () { if (from) moved = true; });
357 +
      a.addEventListener("click", function (ev) { if (moved) ev.preventDefault(); });
358 +
    });
359 +
    document.addEventListener("pointerup", function (ev) {
360 +
      var start = from;
361 +
      from = null;
362 +
      if (!start) return;
363 +
      var el = document.elementFromPoint(ev.clientX, ev.clientY), onto = el && el.closest ? el.closest("[data-change]") : null;
364 +
      if (!onto || onto.getAttribute("data-change") === start) return;
365 +
      var body = new URLSearchParams({ _csrf: plan.getAttribute("data-csrf"), change: start, target: onto.getAttribute("data-change") });
366 +
      fetch(plan.getAttribute("data-plan"), { method: "POST", credentials: "same-origin", body: body })
367 +
        .then(function (r) { return r.json(); })
368 +
        .then(function (d) {
369 +
          panel.hidden = false;
370 +
          panel.textContent = "";
371 +
          if (d.error) { panel.textContent = d.error; return; }
372 +
          d.options.forEach(function (o) {
373 +
            var p = document.createElement("p");
374 +
            p.appendChild(document.createTextNode(o.summary + " "));
375 +
            var a = document.createElement("a");
376 +
            a.href = o.vscode;
377 +
            a.textContent = "Apply in VS Code";
378 +
            p.appendChild(a);
379 +
            var code = document.createElement("pre");
380 +
            code.className = "snippet";
381 +
            code.textContent = o.command;
382 +
            panel.appendChild(p);
383 +
            panel.appendChild(code);
384 +
          });
385 +
        });
386 +
    });
387 +
  }
346 388
})();
modified crates/hub/src/web/history.rs +149 −1
…
136 unchanged lines
137 137
    target: view::RefTarget,
138 138
    total: usize,
139 139
    board: graph::Board,
140 +
    /// The open proposal whose source this bookmark is, when the viewer may
141 +
    /// rewrite it: cards can then be dragged onto each other (P3b).
142 +
    proposal: Option<i64>,
140 143
}
141 144
142 145
/// `GET /{owner}/{name}/lanes/{ref}`: a bookmark's newest changes as
…
16 unchanged lines
159 162
        .await
160 163
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
161 164
    let board = graph::board(&h.entries);
162 -
    Ok(Page(LanesPage { shell, repo: RepoShell::new(&app, &row, "changes").with_role(role), target: h.target, total: h.total, board }))
165 +
    let proposal = editable_proposal(&app, &viewer, &row, role, &r).await.map(|p| p.number);
166 +
    Ok(Page(LanesPage {
167 +
        shell,
168 +
        repo: RepoShell::new(&app, &row, "changes").with_role(role),
169 +
        target: h.target,
170 +
        total: h.total,
171 +
        board,
172 +
        proposal,
173 +
    }))
174 +
}
175 +
176 +
/// The open proposal whose source is `bookmark` of this repository, when
177 +
/// the viewer is its author or may write here: only those bookmarks are
178 +
/// rewritten from the board, and only by their owners' clients.
179 +
async fn editable_proposal(
180 +
    app: &Shared,
181 +
    viewer: &Viewer,
182 +
    row: &crate::db::repos::RepoRow,
183 +
    role: crate::auth::Role,
184 +
    bookmark: &str,
185 +
) -> Option<crate::db::proposals::ProposalRow> {
186 +
    let me = viewer.account()?;
187 +
    let open = crate::db::proposals::list(&app.db, row.id, &["open", "draft"]).await.ok()?;
188 +
    open.into_iter().find(|p| {
189 +
        p.source_bookmark == bookmark
190 +
            && p.source_repo_id.is_none_or(|id| id == row.id)
191 +
            && (p.author_id == Some(me.id) || role >= crate::auth::Role::Write)
192 +
    })
193 +
}
194 +
195 +
/// One rewrite the board offers for a drop (hub spec §6.7).
196 +
#[derive(serde::Serialize)]
197 +
pub struct PlanOption {
198 +
    /// `squash` or `rebase`.
199 +
    kind: &'static str,
200 +
    summary: String,
201 +
    /// What to run in a checkout of the bookmark.
202 +
    command: String,
203 +
    /// The same, handed to oxygen-vscode.
204 +
    vscode: String,
205 +
}
206 +
207 +
#[derive(Deserialize)]
208 +
pub struct PlanForm {
209 +
    #[serde(rename = "_csrf")]
210 +
    csrf_token: String,
211 +
    /// The dragged change and the one it was dropped on, full hashes.
212 +
    change: String,
213 +
    target: String,
214 +
}
215 +
216 +
/// The rewrites a drop may mean, for a proposal bookmark (pure, tested):
217 +
/// squashing `c` into `t` when `c` needs nothing `t` lacks, rebasing `c`
218 +
/// onto `t` when `t` does not already have it. Reordering independent
219 +
/// changes means nothing in Oxygen, so a drop never offers it.
220 +
pub fn plan_options(state: &oxy_core::state::State, c: &oxy_core::hash::ChangeHash, t: &oxy_core::hash::ChangeHash) -> Vec<&'static str> {
221 +
    use oxy_core::graph::DepOracle;
222 +
    if c == t || !state.contains(c) || !state.contains(t) || state.depends_on(t, c) {
223 +
        return vec![];
224 +
    }
225 +
    let mut out = Vec::new();
226 +
    let closure = state.closure(t);
227 +
    let deps_ok = state.get(c).is_some_and(|m| m.deps.iter().all(|d| d == t || closure.contains(d)));
228 +
    if deps_ok {
229 +
        out.push("squash");
230 +
    }
231 +
    if !state.get(c).is_some_and(|m| m.deps.contains(t)) {
232 +
        out.push("rebase");
233 +
    }
234 +
    out
235 +
}
236 +
237 +
/// `POST /{owner}/{name}/lanes/{bookmark}/plan`: what dropping one card on
238 +
/// another would do, as commands for the author's client to run and sign.
239 +
/// The hub rewrites nothing itself.
240 +
pub async fn plan(
241 +
    State(app): State<Shared>,
242 +
    viewer: Viewer,
243 +
    Path((owner, name, r)): Path<(String, String, String)>,
244 +
    axum::extract::Form(f): axum::extract::Form<PlanForm>,
245 +
) -> Result<Response, Error> {
246 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/lanes/{r}"), &viewer);
247 +
    super::settings::csrf(&viewer, &f.csrf_token, &shell)?;
248 +
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
249 +
    let problem = |msg: &str| (axum::http::StatusCode::BAD_REQUEST, axum::Json(serde_json::json!({ "error": msg }))).into_response();
250 +
    let Some(proposal) = editable_proposal(&app, &viewer, &row, role, &r).await else {
251 +
        return Ok(problem("Changes are rearranged only on the bookmark of an open proposal you may change."));
252 +
    };
253 +
    let (Some(c), Some(t)) = (oxy_core::hash::ChangeHash::from_hex(&f.change), oxy_core::hash::ChangeHash::from_hex(&f.target)) else {
254 +
        return Ok(problem("Name two changes."));
255 +
    };
256 +
    let (spec, target_bookmark) = (r.clone(), proposal.target.clone());
257 +
    let found = app
258 +
        .repos
259 +
        .read(&repo, move |repo, cache| {
260 +
            let here = view::resolve(repo, cache, &spec)?;
261 +
            let loaded = cache.load(repo, &here.heads)?;
262 +
            // What has landed on the target is history, not a draft.
263 +
            let landed = match repo.view()?.bookmarks.get(&target_bookmark) {
264 +
                Some(st) => {
265 +
                    let heads = repo.store().get_state(st)?.heads;
266 +
                    let target_state = cache.load(repo, &heads)?;
267 +
                    target_state.state.contains(&c) || target_state.state.contains(&t)
268 +
                }
269 +
                None => false,
270 +
            };
271 +
            let meta = |h: &oxy_core::hash::ChangeHash| loaded.state.get(h).map(|m| m.change_id.short(8));
272 +
            Ok((plan_options(&loaded.state, &c, &t), landed, meta(&c), meta(&t)))
273 +
        })
274 +
        .await
275 +
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
276 +
    let (kinds, landed, Some(cid), Some(tid)) = found else {
277 +
        return Ok(problem("Both changes must be on this bookmark."));
278 +
    };
279 +
    if landed {
280 +
        return Ok(problem(&format!("That change is already on {}; only the proposal's own changes are rearranged.", proposal.target)));
281 +
    }
282 +
    if kinds.is_empty() {
283 +
        return Ok(problem("That drop means nothing here: the change is already part of the other, or they are the same."));
284 +
    }
285 +
    let base = format!("{}/{owner}/{name}", app.config.base_url().trim_end_matches('/'));
286 +
    let enc = super::settings::urlencode;
287 +
    let options: Vec<PlanOption> = kinds
288 +
        .into_iter()
289 +
        .map(|kind| {
290 +
            let (summary, command) = match kind {
291 +
                "squash" => (
292 +
                    format!("Squash {cid} into {tid}: one change instead of two."),
293 +
                    format!("oxy squash #{} --into #{}", c.to_hex(), t.to_hex()),
294 +
                ),
295 +
                _ => (
296 +
                    format!("Rebase {cid} onto {tid}: {cid} comes to depend on {tid}."),
297 +
                    format!("oxy rebase #{} --onto #{}", c.to_hex(), t.to_hex()),
298 +
                ),
299 +
            };
300 +
            let vscode = format!(
301 +
                "vscode://oxygen.oxygen-vscode/plan?url={}&bookmark={}&kind={kind}&change={}&target={}",
302 +
                enc(&base),
303 +
                enc(&r),
304 +
                c.to_hex(),
305 +
                t.to_hex()
306 +
            );
307 +
            PlanOption { kind, summary, command: format!("oxy bookmark switch {r} && {command} && oxy push --force"), vscode }
308 +
        })
309 +
        .collect();
310 +
    Ok(axum::Json(serde_json::json!({ "proposal": proposal.number, "options": options })).into_response())
163 311
}
164 312
165 313
/// A file's diff, rendered.
…
201 unchanged lines
modified crates/hub/src/web/mod.rs +1 −0
…
95 unchanged lines
96 96
        .route("/{owner}/{name}/file/{r}/{*path}", get(repo::file))
97 97
        .route("/{owner}/{name}/function/{r}/{*path}", get(history::function))
98 98
        .route("/{owner}/{name}/lanes/{r}", get(history::lanes))
99 +
        .route("/{owner}/{name}/lanes/{r}/plan", post(history::plan))
99 100
        .route("/{owner}/{name}/raw/{r}/{*path}", get(repo::raw_file))
100 101
        .route("/{owner}/{name}/last/{r}", get(repo::last_changes))
101 102
        .route("/{owner}/{name}/changes/{r}", get(history::history))
…
487 unchanged lines
modified crates/hub/tests/api.rs +75 −0
…
968 unchanged lines
969 969
    assert_eq!(board.matches("<path d=\"M").count(), 1, "grow a depends on add a only");
970 970
    assert!(r.body.contains("grow a") && r.body.contains("/andy/demo/change/"));
971 971
}
972 +
973 +
/// Planning rewrites from a proposal's swimlanes (hub spec §6.7): the hub
974 +
/// offers only what makes sense and rewrites nothing itself.
975 +
#[tokio::test(flavor = "multi_thread")]
976 +
async fn a_proposal_board_plans_squashes_and_rebases() {
977 +
    let h = hub().await;
978 +
    let app = &h.app;
979 +
    let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery"))
980 +
        .await
981 +
        .cookie
982 +
        .unwrap();
983 +
    let page = call(app, "GET", "/new", None, Some(&andy), None, None).await;
984 +
    call(app, "POST", "/new", None, Some(&andy), None, Some(&format!("_csrf={}&owner=andy&name=demo", csrf_of(&page.body)))).await;
985 +
    let url = format!("{}/andy/demo", h.base);
986 +
    let work = tempfile::tempdir().unwrap();
987 +
    let w = work.path().to_path_buf();
988 +
    let (base, first, second, apart) = tokio::task::spawn_blocking(move || {
989 +
        let mut c = Repo::init(&w).unwrap();
990 +
        c.config.set("remote.origin.url", &url).unwrap();
991 +
        c.config.set("remote.origin.token", "site").unwrap();
992 +
        c.save_config().unwrap();
993 +
        write(&w, "a.txt", b"a\n");
994 +
        let base = c.commit("base", &[]).unwrap();
995 +
        assert!(c.push("origin", false).unwrap()[0].ok);
996 +
        c.bookmark_create("feature", None).unwrap();
997 +
        c.bookmark_switch("feature", false).unwrap();
998 +
        write(&w, "a.txt", b"a\nfirst\n");
999 +
        let first = c.commit("first", &[]).unwrap();
1000 +
        write(&w, "a.txt", b"a\nfirst\nsecond\n");
1001 +
        let second = c.commit("second", &[]).unwrap();
1002 +
        write(&w, "b.txt", b"apart\n");
1003 +
        let apart = c.commit("apart", &[]).unwrap();
1004 +
        assert!(c.push_bookmark("origin", "feature", false).unwrap()[0].ok);
1005 +
        (base.to_hex(), first.to_hex(), second.to_hex(), apart.to_hex())
1006 +
    })
1007 +
    .await
1008 +
    .unwrap();
1009 +
    // No proposal yet: the board is read-only.
1010 +
    let board = call(app, "GET", "/andy/demo/lanes/feature", None, Some(&andy), None, None).await.body;
1011 +
    assert!(!board.contains("data-plan="));
1012 +
    let (st, _) = api(
1013 +
        app,
1014 +
        "POST",
1015 +
        "/api/v1/repos/andy/demo/proposals",
1016 +
        Some("site"),
1017 +
        Some(r#"{"title":"Feature","target":"main","source":"feature"}"#),
1018 +
    )
1019 +
    .await;
1020 +
    assert!(st.is_success(), "{st}");
1021 +
    let board = call(app, "GET", "/andy/demo/lanes/feature", None, Some(&andy), None, None).await.body;
1022 +
    assert!(board.contains("data-plan=\"/andy/demo/lanes/feature/plan\""), "{board}");
1023 +
    let csrf = csrf_of(&board);
1024 +
    let plan = |c: &str, t: &str| {
1025 +
        let (app, andy, csrf) = (app.clone(), andy.clone(), csrf.clone());
1026 +
        let form = format!("_csrf={csrf}&change={c}&target={t}");
1027 +
        async move {
1028 +
            let r = call(&app, "POST", "/andy/demo/lanes/feature/plan", None, Some(&andy), None, Some(&form)).await;
1029 +
            serde_json::from_str::<serde_json::Value>(&r.body).unwrap()
1030 +
        }
1031 +
    };
1032 +
    let kinds = |v: &serde_json::Value| {
1033 +
        v["options"].as_array().map(|a| a.iter().map(|o| o["kind"].as_str().unwrap().to_string()).collect::<Vec<_>>()).unwrap_or_default()
1034 +
    };
1035 +
    // second already depends on first: squash, no rebase.
1036 +
    let v = plan(&second, &first).await;
1037 +
    assert_eq!(kinds(&v), ["squash"], "{v}");
1038 +
    assert!(v["options"][0]["command"].as_str().unwrap().contains(&format!("oxy squash #{second} --into #{first}")));
1039 +
    assert!(v["options"][0]["vscode"].as_str().unwrap().starts_with("vscode://oxygen.oxygen-vscode/plan?"));
1040 +
    // apart is independent of second: either.
1041 +
    assert_eq!(kinds(&plan(&apart, &second).await), ["squash", "rebase"]);
1042 +
    // first under second: nothing (second depends on first).
1043 +
    assert!(plan(&first, &second).await["error"].is_string());
1044 +
    // What has landed on main is not rearranged.
1045 +
    assert!(plan(&second, &base).await["error"].as_str().unwrap().contains("already on main"));
1046 +
}
modified docs/spec.md +2 −0
…
293 unchanged lines
294 294
295 295
**Swimlanes.** `/{owner}/{name}/lanes/{ref}` draws a bookmark's newest changes (up to 60) as a board: each lane of the shared lane layout (`oxy_core::lanes`, as `oxy log` and the list view use) is a row, time runs left to right, every change is a card linking to its page, and an edge joins a change to each change of the board it depends on. Independent work stays in separate lanes and there are no merge nodes, since Oxygen has none. The history page links to it.
296 296
297 +
**Planning on the board.** When the bookmark is the source of an open or draft proposal that the viewer wrote or can write to, the board is editable. Dragging a change onto another asks `POST /{owner}/{name}/lanes/{ref}/plan` (form `change`, `target`) what the drop could mean. The answer is JSON: a **squash** (fold the change into the target) when every dependency of the change is the target or already beneath it, and a **rebase** (make the change depend on the target) unless it already does. Nothing is offered when the target depends on the change, or when either is already on the proposal's target bookmark. The hub does not rewrite the changes itself: rewritten changes must be signed by their author, so each option carries the `oxy` commands to run and a `vscode://oxygen.oxygen-vscode/plan?…` link that does the same in the VS Code extension, then force-pushes the bookmark.
298 +
297 299
### 6.8 Change page
298 300
299 301
Header: full hash, message, author identity (linked account or KeyId), timestamp, signature status with the policy rule that admitted it (or "unsigned", or "invalid"), seals covering it, the bookmarks and tags containing it. Body: dependencies and dependents as chips, the change's edits as a diff (unified or split, per-user preference), atom counts, and the raw change (`oxy show --json`) behind a details block.
…
289 unchanged lines
modified templates/lanes.html +4 −2
…
7 unchanged lines
8 8
<div class="head"><h1>Swimlanes <small>{{ target.label }} · {{ board.cards.len() }}{% if total > board.cards.len() %} newest of {{ total }}{% endif %} changes in {{ board.lanes }} lane{% if board.lanes != 1 %}s{% endif %}</small></h1>
9 9
<p class="small mute">Changes that do not depend on each other run in separate lanes, oldest on the left; an edge joins a change to what it depends on. There are no merge nodes: parallel work simply stays parallel. <a href="{{ repo.url }}/changes/{{ target.label }}">list view</a></p></div>
10 10
{% if board.cards.is_empty() %}<p class="empty">No changes.</p>{% else %}
11 -
<div class="lanes-board" style="overflow-x: auto">
11 +
{% if let Some(n) = proposal %}<p class="small">This is the bookmark of proposal <a href="{{ repo.url }}/proposal/{{ n }}">#{{ n }}</a>: drag a change onto another to plan a squash or a rebase. The hub only writes the plan; your oxy or oxygen-vscode carries it out and signs it.</p>
12 +
<div class="panel" data-plan-panel hidden></div>{% endif %}
13 +
<div class="lanes-board" style="overflow-x: auto"{% if proposal.is_some() %} data-plan="{{ repo.url }}/lanes/{{ target.label }}/plan" data-csrf="{{ shell.csrf }}"{% endif %}>
12 14
<svg width="{{ board.width }}" height="{{ board.height }}" viewBox="0 0 {{ board.width }} {{ board.height }}" role="img" aria-label="Swimlanes of {{ target.label }}">
13 15
  {% for e in board.edges %}<path d="{{ e }}" class="lane" fill="none"/>{% endfor %}
14 16
  {% for c in board.cards %}
15 -
  <a href="{{ repo.url }}/change/{{ c.hash }}">
17 +
  <a href="{{ repo.url }}/change/{{ c.hash }}" data-change="{{ c.hash }}">
16 18
    <title>{{ c.short }} {{ c.title }}</title>
17 19
    <rect x="{{ c.x }}" y="{{ c.y }}" width="{{ crate::render::graph::CARD_W }}" height="{{ crate::render::graph::CARD_H }}" rx="6" class="node{% if c.head %} head{% endif %}"/>
18 20
    <text x="{{ c.x + 8 }}" y="{{ c.y + 14 }}" class="small">{{ c.short }}</text>
…
7 unchanged lines