Merge branch 'iop-vc-preview' into branch-id-reroute

This commit is contained in:
Álvaro Tejero Cantero 2026-10-28 12:40:25 +01:00
commit 8444ee5595
No known key found for this signature in database
9 changed files with 35 additions and 286 deletions

View File

@ -702,6 +702,7 @@
JOIN file AS sf ON (sf.id = fb.source_file_id)
WHERE fb.source_file_id = ?
AND fb.deleted_at IS NULL
AND fb.status != 'merged'
AND (?::boolean OR fb.status = 'open')
ORDER BY fb.created_at DESC")
@ -1091,12 +1092,7 @@
[:expected-main-revn {:optional true} ::sm/int]
;; what the user says about the merge; kept in its record
;; (`file_branch_merge`), shown in the list of merges of main
[:comment {:optional true} [:string {:max 4000}]]
;; when false (the default) the branch and its file are logically
;; deleted right after a successful merge, in the SAME transaction (so
;; merged copies do not pile up); when true the branch is kept as
;; "merged", staying readable with status merged until it is deleted.
[:keep-branch {:optional true} ::sm/boolean]])
[:comment {:optional true} [:string {:max 4000}]]])
(sv/defmethod ::merge-file-branch
"Merge a branch into its source file (main).
@ -1109,15 +1105,15 @@
`{:status :unsupported}` so no change is silently dropped. Otherwise it
takes a safety snapshot of main, applies the merge to main through the
production change pipeline, records the merge with its `comment`
(`file_branch_merge`), deletes the branch and its file unless
`keep-branch` asks to keep it, and notifies open clients via msgbus."
(`file_branch_merge`), deletes the branch and its file and notifies
open clients via msgbus."
{::doc/added "2.16"
::webhooks/event? true
::sm/params schema:merge-file-branch
::climit/id [[:merge-file-branch/by-profile ::rpc/profile-id]
[:merge-file-branch/global]]}
[{:keys [::mbus/msgbus] :as cfg}
{:keys [::rpc/profile-id ::rpc/session-id branch-id resolutions expected-main-revn comment keep-branch excluded]}]
{:keys [::rpc/profile-id ::rpc/session-id branch-id resolutions expected-main-revn comment excluded]}]
(bp/with-policies
(check-branching-enabled!)
(let [branch (db/get* cfg :file-branch {:id branch-id})]
@ -1207,16 +1203,13 @@
finish-branch!
(fn [ts]
;; record the merge and mark the branch merged. The
;; pinned base snapshot stays for a KEPT branch (its
;; state is derived from that snapshot and the op
;; log); delete-branch! releases it on the delete
;; path. Unless the user chose to keep it the branch
;; and its file are deleted in this same transaction
;; record the merge, mark the branch merged and
;; delete it and its file in this same transaction
;; (no client-driven second call, no window where a
;; crash leaves a stale copy). The record is written
;; first: it reads the open pull request the lines
;; below close.
;; crash leaves a stale copy); delete-branch!
;; releases the pinned base snapshot. The record is
;; written first: it reads the open pull request the
;; lines below close.
(record-merge! conn branch {:profile-id profile-id
:comment comment
:created-at ts})
@ -1235,10 +1228,9 @@
{:profile-id profile-id
:status "merged"
:deleted-at (ct/in-future delay)})
(when-not keep-branch
(vswap! messages conj
(delete-branch! cfg branch {:profile-id profile-id
:session-id session-id})))))]
(vswap! messages conj
(delete-branch! cfg branch {:profile-id profile-id
:session-id session-id}))))]
(cond
(seq unresolved)
(audited {:status :conflicts :conflicts conflicts :main-revn (:revn main-file)}
@ -1940,6 +1932,7 @@
"SELECT count(*) AS count
FROM file_branch AS fb
WHERE fb.deleted_at IS NULL
AND fb.status != 'merged'
AND fb.source_file_id = coalesce((SELECT b.source_file_id
FROM file_branch AS b
WHERE b.branch_file_id = ?
@ -1947,8 +1940,8 @@
?)")
(sv/defmethod ::get-file-branch-count
"How many branches the file has, archived and merged ones included, as
the listing with `:include-archived` returns them. When the file is
"How many branches the file has, archived ones included, as the
listing with `:include-archived` returns them. When the file is
itself a branch, the branches of its source (main) are counted.
The workspace header asks this on every file open to decide whether to

View File

@ -15,7 +15,6 @@
[app.common.data :as d]
[app.common.features :as cfeat]
[app.common.files.migrations :as fmg]
[app.common.time :as ct]
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]
[app.config :as cf]
@ -1412,67 +1411,3 @@
row (th/db-get :file-media-object {:id ref})]
(t/is (some? row))
(t/is (= main-id (:file-id row)))))))
(t/deftest merge-with-keep-branch-leaves-the-branch-readable
(with-redefs [cf/flags (conj cf/flags :branching)]
(let [profile (th/create-profile* 1 {:is-active true})
proj-id (:default-project-id profile)
file (th/create-file* 1 {:profile-id (:id profile)
:project-id proj-id
:is-shared false})
create (create-branch* profile (:id file) "oplog-keep")
branch-file-id (:branch-file-id create)
branch-id (:id create)
color-id (uuid/random)]
(apply-change* profile branch-file-id
{:type :add-color
:color {:id color-id :name "C" :color "#112233" :opacity 1}})
(let [out (th/command! {::th/type :merge-file-branch
::rpc/profile-id (:id profile)
:branch-id branch-id
:keep-branch true})]
(t/is (nil? (:error out)))
(t/is (= :merged (-> out :result :status))))
(t/testing "the kept branch still derives its state"
(let [out (th/command! {::th/type :get-file
::rpc/profile-id (:id profile)
:id branch-file-id})]
(t/is (nil? (:error out)))
(t/is (contains? (-> out :result :data :colors) color-id))))
(t/testing "the base pin survives while the branch is kept"
(let [rows (->> (th/db-query :file-change {:file-id (:id file)})
(filter #(some-> (:label %) (str/starts-with? "branch-base/"))))]
(t/is (seq rows))
(t/is (every? (fn [{:keys [deleted-at]}]
(or (nil? deleted-at)
(ct/is-after? deleted-at (ct/in-future {:days 400}))))
rows)))))))
(t/deftest a-kept-merged-branch-refuses-saves
(with-redefs [cf/flags (conj cf/flags :branching)]
(let [profile (th/create-profile* 1 {:is-active true})
proj-id (:default-project-id profile)
file (th/create-file* 1 {:profile-id (:id profile)
:project-id proj-id
:is-shared false})
create (create-branch* profile (:id file) "oplog-merged")
branch-file-id (:branch-file-id create)
branch-id (:id create)]
(th/command! {::th/type :merge-file-branch
::rpc/profile-id (:id profile)
:branch-id branch-id
:keep-branch true})
(let [out (apply-change* profile branch-file-id
{:type :add-color
:color {:id (uuid/random) :name "C" :color "#112233" :opacity 1}})]
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :branch-merged)))
(t/testing "nothing reaches the op log"
(t/is (empty? (oplog-rows branch-file-id)))))))

View File

@ -3236,60 +3236,3 @@
(= main-1 (:id %))
(= :deleted (:status %)))
(:changes diff))))))))
(t/deftest merge-with-keep-branch-archives-it
(with-redefs [cf/flags (conj cf/flags :branching)]
(let [profile (th/create-profile* 1 {:is-active true})
proj-id (:default-project-id profile)
file (th/create-file* 1 {:profile-id (:id profile)
:project-id proj-id})
create (:result (th/command! {::th/type :create-file-branch
::rpc/profile-id (:id profile)
:file-id (:id file)
:name "kept"}))
branch-id (:id create)
branch-file-id (:branch-file-id create)]
(let [out (th/command! {::th/type :merge-file-branch
::rpc/profile-id (:id profile)
:branch-id branch-id
:keep-branch true})]
(t/is (nil? (:error out)))
(t/is (= :merged (-> out :result :status))))
(let [[row] (th/db-query :file-branch {:id branch-id})
bf (th/db-get :file {:id branch-file-id})]
(t/is (= "merged" (:status row)))
(t/is (nil? (:deleted-at row)))
(t/is (nil? (:deleted-at bf)))))))
(t/deftest delete-keeps-the-status-the-branch-carries
;; deleting a kept merged branch must not rewrite its status: the
;; record is the only place left that says the branch was merged
(with-redefs [cf/flags (conj cf/flags :branching)]
(let [profile (th/create-profile* 1 {:is-active true})
proj-id (:default-project-id profile)
file (th/create-file* 1 {:profile-id (:id profile)
:project-id proj-id})
create (:result (th/command! {::th/type :create-file-branch
::rpc/profile-id (:id profile)
:file-id (:id file)
:name "kept"}))
branch-id (:id create)
branch-file-id (:branch-file-id create)
cid (uuid/random)]
(apply-change* profile branch-file-id
{:type :add-color :color {:id cid :name "C" :color "#112233" :opacity 1}})
(let [out (th/command! {::th/type :merge-file-branch
::rpc/profile-id (:id profile)
:branch-id branch-id
:keep-branch true})]
(t/is (= :merged (-> out :result :status))))
(let [out (th/command! {::th/type :delete-file-branch
::rpc/profile-id (:id profile)
:id branch-id})]
(t/is (nil? (:error out)))
(t/is (= :deleted (-> out :result :status))))
(let [[row] (th/db-query :file-branch {:id branch-id})]
(t/is (= "merged" (:status row)))
(t/is (some? (:deleted-at row)))))))

View File

@ -792,78 +792,3 @@
(t/is (empty? @seen))
(t/is (empty? (th/db-query :file-pull-request
{:file-branch-id (:id branch)})))))))
(t/deftest create-serializes-against-a-kept-concurrent-merge
;; same race, but the merge keeps the branch: its row stays readable
;; with status "merged" and its file is not deleted, so without the
;; re-read the create sails past the stale status check and INSERTS a
;; pull request for a branch the merge already closed.
(with-redefs [cf/flags pr-flags]
(let [author (th/create-profile* 1 {:is-active true})
file (th/create-file* 1 {:profile-id (:id author)
:project-id (:default-project-id author)})
branch (create-branch* author file "contested")
branch-id (:id branch)
branch-file-id (:branch-file-id branch)]
(add-color* author branch-file-id "Brand")
(let [orig-lock db/xact-lock!
merging? (atom false)
merged (atom nil)]
(with-redefs [db/xact-lock!
(fn [conn id]
;; the first lock the create takes is the signal:
;; merge the same branch through the pool in its
;; own transaction BEFORE the create locks
(when (compare-and-set! merging? false true)
(reset! merged (th/command! {::th/type :merge-file-branch
::rpc/profile-id (:id author)
:branch-id branch-id
:keep-branch true})))
(orig-lock conn id))]
(let [out (th/command! {::th/type :create-pull-request
::rpc/profile-id (:id author)
:branch-id branch-id
:title "review me"})]
(t/is (= :merged (-> @merged :result :status)))
(t/is (= "merged" (:status (first (th/db-query :file-branch {:id branch-id})))))
(t/is (= :branch-not-open (-> out :error ex-data :code)))
(t/is (= [] (th/db-query :file-pull-request {:file-branch-id branch-id})))))))))
(t/deftest reopen-serializes-against-a-kept-concurrent-merge
;; same race for the reopen: the kept merge leaves the branch row
;; readable with status "merged", so without the re-read the reopen
;; sails past the stale status check and REOPENS the closed pull
;; request over a branch the merge already closed.
(with-redefs [cf/flags pr-flags]
(let [author (th/create-profile* 1 {:is-active true})
file (th/create-file* 1 {:profile-id (:id author)
:project-id (:default-project-id author)})
branch (create-branch* author file "contested")
branch-id (:id branch)
branch-file-id (:branch-file-id branch)]
(add-color* author branch-file-id "Brand")
(let [pr (:result (create-pr* author branch-id {}))]
(t/is (nil? (:error (th/command! {::th/type :close-pull-request
::rpc/profile-id (:id author)
:id (:id pr)}))))
(let [orig-lock db/xact-lock!
merging? (atom false)
merged (atom nil)]
(with-redefs [db/xact-lock!
(fn [conn id]
;; the first lock the reopen takes is the signal:
;; merge the same branch through the pool in its
;; own transaction BEFORE the reopen locks
(when (compare-and-set! merging? false true)
(reset! merged (th/command! {::th/type :merge-file-branch
::rpc/profile-id (:id author)
:branch-id branch-id
:keep-branch true})))
(orig-lock conn id))]
(let [out (th/command! {::th/type :reopen-pull-request
::rpc/profile-id (:id author)
:id (:id pr)})]
(t/is (= :merged (-> @merged :result :status)))
(t/is (= "merged" (:status (first (th/db-query :file-branch {:id branch-id})))))
(t/is (= :branch-not-open (-> out :error ex-data :code)))
(t/is (= "closed" (:status (first (th/db-query :file-pull-request {:id (:id pr)}))))))))))))

View File

@ -745,19 +745,14 @@
(defn merge-branch
"Merge `branch` (a branch row/context map with at least `:id`) into main.
On success the server records the merge and, unless the branch is kept,
deletes the branch and its file; open clients reload main via the
`:file-merged` msgbus event.
On success the server records the merge and deletes the branch and its
file, and open clients reload main via the `:file-merged` msgbus event.
Options:
- `:resolutions` — optional `{entity-id (:main|:branch)}` map used to
resolve conflicts before integrating.
- `:comment` — what the user says about the merge, kept with its
record and shown in the reviews dialog.
- `:keep-branch` — when true the merged branch is kept (it shows up
under \"Archived\"); when false (default) the server deletes the
branch and its file in the same transaction, so merged copies do not
pile up and eat disk space.
- `:excluded` — the per-change selection (change keys,
`app.common.files.branch-merge/change-key`) naming the changes the
user deselected, which main keeps its own value for.
@ -769,7 +764,7 @@
before that diff has loaded carry the revn of the conflict answer instead
(`conflict-main-revn`). Unresolved conflicts open the resolution modal."
([branch] (merge-branch branch nil))
([branch {:keys [resolutions comment keep-branch excluded]}]
([branch {:keys [resolutions comment excluded]}]
(assert (uuid? (:id branch)) "expected a branch row with a valid `:id`")
(ptk/reify ::merge-branch
ptk/WatchEvent
@ -787,7 +782,6 @@
(seq resolutions) (assoc :resolutions resolutions)
(seq excluded) (assoc :excluded excluded)
(not (str/blank? comment)) (assoc :comment (str/trim comment))
keep-branch (assoc :keep-branch true)
(some? main-revn) (assoc :expected-main-revn main-revn)))
(rx/mapcat
(fn [{:keys [status source-file-id conflicts] :as answer}]
@ -795,8 +789,8 @@
;; the merged result lives in main: take the user there
;; to see it (navigate if elsewhere, hard-reload if
;; already on main). Other clients reload via the
;; `:file-merged` event; the branch deletion (when not
;; kept) already happened server-side.
;; `:file-merged` event; the branch deletion already
;; happened server-side.
:merged (rx/of (ntf/success (tr "workspace.branches.merge.success"))
(show-merge-result source-file-id))
:conflicts (rx/of (open-conflict-resolutions {:branch branch

View File

@ -24,10 +24,10 @@
;; --- Merge / update confirmations
(defn confirm-merge!
"Open the merge dialog for `branch` (merge into main is irreversible; the
dialog asks for a comment for the merge's record and lets the user choose
whether to keep the branch archived). `excluded` is the compare dialog's
per-change selection, if any."
"Open the merge dialog for `branch` (merge into main is irreversible and
deletes the branch; the dialog asks for a comment for the merge's
record). `excluded` is the compare dialog's per-change selection, if
any."
([branch] (confirm-merge! branch nil))
([branch excluded]
(modal/show! :merge-branch {:branch branch :excluded excluded})))

View File

@ -18,7 +18,6 @@
[app.main.data.workspace.branches :as dwb]
[app.main.store :as st]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.controls.checkbox :refer [checkbox*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.foundations.typography :as t]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
@ -393,8 +392,6 @@
(let [{:keys [diff resolutions status]} (mf/deref branch-diff)
loading? (= status :loading)
keep?* (mf/use-state false)
keep? (deref keep?*)
selected* (mf/use-state 0)
selected (deref selected*)
@ -465,7 +462,7 @@
(when next-ix (reset! selected* next-ix)))))
on-apply (mf/use-fn
;; a merge sends again the comment of the merge dialog
(mf/deps branch resolutions mode keep? excluded comment conflicts)
(mf/deps branch resolutions mode excluded comment conflicts)
(fn []
(let [ids (into #{} (map :id) conflicts)
res (into {} (filter (fn [[id _]] (contains? ids id))) resolutions)]
@ -473,7 +470,6 @@
(dwb/update-branch-from-main branch {:resolutions res
:excluded excluded})
(dwb/merge-branch branch {:resolutions res
:keep-branch keep?
:comment comment
:excluded excluded}))))))]
@ -552,12 +548,7 @@
(if all-done?
[:div {:class (stl/css :footer-start)}
[:> text* {:as "span" :typography t/body-small :class (stl/css :footer-ready)}
(tr "workspace.branches.conflicts.footer-ready")]
(when (not= mode :update)
[:> checkbox* {:id "conflicts-archive-branch"
:label (tr "workspace.branches.merge.archive-label")
:checked keep?
:on-change #(swap! keep?* not)}])]
(tr "workspace.branches.conflicts.footer-ready")]]
[:> text* {:as "span" :typography t/body-small :class (stl/css :footer-blocked)}
(tr "workspace.branches.resolve.footer.blocked" (i18n/c pending))]))
:footer-end

View File

@ -17,7 +17,6 @@
[app.main.store :as st]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.checkbox :refer [checkbox*]]
[app.main.ui.ds.controls.input :refer [input*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.layout.menu :refer [menu* menu-item* menu-separator*]]
@ -262,9 +261,7 @@
{::mf/register modal/components
::mf/register-as :merge-branch}
[{:keys [branch excluded]}]
(let [archive?* (mf/use-state false)
archive? (deref archive?*)
comment* (mf/use-state "")
(let [comment* (mf/use-state "")
comment (deref comment*)
;; what goes in and what stays out, when the compare dialog of this
@ -297,9 +294,6 @@
(str sep (tr "workspace.branches.main") sep))]
(str/split msg sep))
on-toggle-archive
(mf/use-fn #(swap! archive?* not))
on-comment-change
(mf/use-fn #(reset! comment* (dom/get-target-val %)))
@ -308,11 +302,9 @@
on-submit
(mf/use-fn
(mf/deps branch archive? comment excluded)
(mf/deps branch comment excluded)
(fn [_]
(st/emit! (dwb/merge-branch branch {:keep-branch archive?
:comment comment
:excluded excluded})
(st/emit! (dwb/merge-branch branch {:comment comment :excluded excluded})
(modal/hide))))]
[:> modal* {:is-open true
@ -344,22 +336,10 @@
:on-change on-comment-change
:rows 3}]]
[:> checkbox* {:id "merge-archive-branch"
:label (tr "workspace.branches.merge.archive-label")
:checked archive?
:on-change on-toggle-archive}]
(cond
archive?
[:> context-notification* {:level :warning :type :context}
(tr "workspace.branches.merge.archive-warning")]
(if (pos? left-out)
;; deleting the branch takes the changes left out with it
(pos? left-out)
[:> context-notification* {:level :warning :type :context}
(tr "workspace.branches.merge.left-out-lost" (i18n/c left-out))]
:else
[:> context-notification* {:level :info :type :context}
(tr "workspace.branches.merge.delete-info")])]

View File

@ -7323,16 +7323,6 @@ msgstr ""
msgid "workspace.branches.merge.confirm-title"
msgstr "Merge into main"
#: src/app/main/ui/workspace/sidebar/branches.cljs:370, src/app/main/ui/workspace/sidebar/branches.cljs:1526
msgid "workspace.branches.merge.archive-label"
msgstr "Archive this branch"
#: src/app/main/ui/workspace/sidebar/branches.cljs:376
msgid "workspace.branches.merge.archive-warning"
msgstr ""
"Archived branches keep a full copy of the file so they can be restored "
"later. Keeping many archived branches can take up a lot of storage."
#: src/app/main/data/workspace/branches.cljs:415
#, unused
msgid "workspace.branches.merge.conflicts"
@ -7340,9 +7330,7 @@ msgstr "This branch has conflicts that must be resolved before merging"
#: src/app/main/ui/workspace/sidebar/branches.cljs:378
msgid "workspace.branches.merge.delete-info"
msgstr ""
"After merging, this branch and its file will be deleted to free up storage. "
"Archive it if you might need to restore it later."
msgstr "After merging, this branch and its file will be deleted to free up storage."
#: src/app/main/data/workspace/branches.cljs:451, src/app/main/data/workspace/branches.cljs:459
msgid "workspace.branches.merge.error"
@ -11800,8 +11788,8 @@ msgstr[1] "%s changes are left out."
#: src/app/main/ui/workspace/sidebar/branches.cljs
msgid "workspace.branches.merge.left-out-lost"
msgid_plural "workspace.branches.merge.left-out-lost"
msgstr[0] "The change you left out is lost when the branch is deleted. Archive the branch to keep it."
msgstr[1] "The %s changes you left out are lost when the branch is deleted. Archive the branch to keep them."
msgstr[0] "The change you left out is lost when the branch is deleted."
msgstr[1] "The %s changes you left out are lost when the branch is deleted."
#: src/app/main/ui/workspace/sidebar/branch_review/compare.cljs
msgid "workspace.branches.review.view.expand"