Skip to content

feat(branches): move a branch to a different project - #919

Open
matt2e wants to merge 6 commits into
mainfrom
move-branch-to-a-different-project
Open

feat(branches): move a branch to a different project#919
matt2e wants to merge 6 commits into
mainfrom
move-branch-to-a-different-project

Conversation

@matt2e

@matt2e matt2e commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Adds a Move to Project… item to the branch card's menu. It opens a searchable project picker and re-parents the branch — with its notes, commits, reviews, sessions and images — into the chosen project.

Store

No schema change is needed: every branch-scoped table references the branch through branch_id and follows it for free. Only three rows carry a project_id of their own, so store::move_branch_to_project rewrites them in one transaction — the branch, its project_repos row, its workdirs row — plus images.project_id and the sessions.working_dir snapshots rooted under the old worktree. UPDATEs only: the AFTER DELETE triggers GC sessions, so delete-and-reinsert would destroy transcripts.

The project_repos row is N:1, so it travels only when the moved branch is the last one on it; otherwise a clone lands in the destination and the siblings keep theirs. A branch with a NULL project_repo_id gets a row materialized rather than carried across, where the resolve_branch_repo_slug fallback would resolve to the destination's primary repo. Both projects then re-elect a primary and re-sync their denormalized github_repo, following remove_project_repo.

On disk

The worktree relocates through a new git worktree move wrapper — a plain rename would leave the gitfile and the repo's gitdir pointer dangling — from wherever workdirs.path says it is, so legacy-layout worktrees move correctly. A failed transaction moves it back, and reports where the worktree actually ended up if it can't. Image files follow per-entry and tolerantly; the diff cache is dropped and rebuilds lazily.

Preconditions and concurrency

Both projects must be local (remote branches share one Blox workspace per project), the destination must not already have the branch's repo + subpath, and no session may be running on the branch. Planning happens twice: a pre-flight that refuses an impossible move before any of the branch's actions are stopped for it, then again inside apply_branch_move, which holds the branch's session launch lock across the re-check, the rename and the transaction — the same lock prs takes around its queue-or-start decisions. That keeps a session from starting between the check and the git worktree move, and serializes two moves of the same branch dispatched from the Tauri command and the web router.

Dialog

The picker states the first two preconditions as a disabled Move button with the reason, using the same NULL-vs-empty subpath key as idx_project_repos_unique, and waits on the target's lazily-hydrated repos before trusting the duplicate check. Keyboard navigation is derived from the selection through a tested helper, so narrowing the list with a query can't leave the highlight past its end.

Styling follows NewSessionModal — flush header bar with a ghost X, 18px body, outline Cancel next to an accent Move — and the project rows are the sidebar's own row, extracted into a shared ProjectRowContent component, so a picker row shows the same status icon, repo badges and live activity the sidebar does.

Verification

just ci: fmt, clippy, svelte-check (0 errors, 0 warnings), 729 Rust and 648 frontend tests.

matt2e and others added 5 commits August 13, 2026 16:39
Adds a "Move to Project…" item to the branch card's `…` menu. It opens a
searchable project picker and re-parents the branch — with its notes,
commits, reviews, sessions and images — into the chosen project.

No schema change is needed: every branch-scoped table references the
branch through `branch_id` and follows it for free. Only three rows carry
a `project_id` of their own, so `store::move_branch_to_project` rewrites
them in one transaction — the branch, its `project_repos` row, its
`workdirs` row — plus `images.project_id` and the `sessions.working_dir`
snapshots rooted under the old worktree. `UPDATE`s only: the `AFTER
DELETE` triggers GC sessions, so delete-and-reinsert would destroy
transcripts.

The `project_repos` row is N:1, so it travels only when the moved branch
is the last one on it; otherwise a clone lands in the destination and the
siblings keep theirs. A branch with a NULL `project_repo_id` gets a row
materialized rather than carried across, where the
`resolve_branch_repo_slug` fallback would resolve to the *destination's*
primary repo. Both projects then re-elect a primary and re-sync their
denormalized `github_repo`, following `remove_project_repo`.

On disk, the worktree relocates through a new `git worktree move` wrapper
— a plain rename would leave the gitfile and the repo's `gitdir` pointer
dangling — from wherever `workdirs.path` says it is, so legacy-layout
worktrees move correctly. A failed transaction moves it back. Image files
follow per-entry and tolerantly; the diff cache is dropped and rebuilds
lazily.

Preconditions are checked before anything mutates: both projects must be
local (remote branches share one Blox workspace per project), the
destination must not already have the branch's repo + subpath, and no
session may be running on the branch. The dialog states the first two as
a disabled Move button with the reason, using the same NULL-vs-empty
subpath key as `idx_project_repos_unique`, and waits on the target's
lazily-hydrated repos before trusting the duplicate check.

Verified with `just ci`: fmt, clippy, svelte-check, 728 Rust and 644
frontend tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Resolves the review on de6de4f.

The "no session is running on this branch" precondition was checked at
plan time with nothing held through the mutation, so a session starting
between the check and the `git worktree move` — or a second move for the
same branch dispatched from the web router — raced the rename. Planning
now happens twice: a pre-flight that refuses an impossible move before
any of the branch's actions are stopped for it, then again inside
`apply_branch_move`, which holds the branch's session launch lock across
the re-check, the rename and the transaction. That is the lock `prs`
already takes around its queue-or-start decisions, and holding it means
the whole mutation runs synchronously on a blocking thread rather than
around an await.

When the transaction fails and the worktree can't be moved back, the
returned error now says where the worktree actually is: reporting only
the store error read as "nothing changed" while the tree sat where
neither project expects it. The crash window between the rename and the
commit is documented rather than closed — `new_path` is derived rather
than stored, so re-running the move finds the worktree already sitting
where the transaction is about to record it, and completes.

`is_unique_violation` matched on SQLite's message text alone, so a future
error that merely mentioned `idx_project_repos_unique` would have been
rewritten into a confident "the destination already has this repository";
it now requires `ErrorCode::ConstraintViolation` first, with a test that
an unrelated failure naming the index keeps its own words.

In the dialog, `highlightedIndex` is derived from the selection instead of
tracked beside it, and the clamping moves into a tested helper. Arrowing
down a long list, narrowing it with a query and then pressing ArrowUp
left the index past the end of the new list, where `selectProject`
received `undefined` and threw; clicking a row likewise left the index
stale for the next arrow press.

Verified with `just ci`: fmt, clippy, svelte-check, 729 Rust and 648
frontend tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
The move-to-project dialog used the stock shadcn dialog chrome —
stacked header, default footer, transparent input — while every other
modal (new session, note, session) shares a house style. It now follows
NewSessionModal: a flush header bar with the title and a ghost X close
button over a border, an 18px-padded body, and an outline Cancel next
to an accent Move button.

The search field and the project list sit on var(--bg-primary) — white
in light mode, the same surface as the session prompt editor — with a
var(--border-muted) border, instead of disappearing into the gray card.
The input's focus ring is swapped for the editor's border-emphasis
treatment, and on mobile the list stretches to fill the full-screen
dialog rather than capping at the desktop height.

Verified with prettier, svelte-check (0 errors) and 648 frontend tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
The move dialog's project rows showed a plain name over full repo-path
labels, so the same projects read differently than they do in the
sidebar, which introduces each one with a state icon and colored repo
badges. The sidebar row's identity block — the PR-status or cloud icon
on the left, the bold name, and the badge/activity meta line with its
repo-count fallback — now lives in a shared ProjectRowContent component
that both the sidebar and MoveBranchDialog render. It derives everything
from the shared stores given only the project, so a picker row shows the
same live state the sidebar does.

The dialog needed nothing new to feed it: opening it already hydrates
every project's branches and repos for the duplicate-repo check, and
hydration is also what materializes the repo badges.

The sidebar's active-row brightening reached its meta text through
scoped descendant selectors, which can't cross into the child, and a
:global svg override would tie with the child's status-icon colors at
equal specificity, leaving the winner to stylesheet order. The meta
color is now a --project-row-meta-color custom property the child
reads, and the stroke override names the two places that still need it
— the row's status spinners and the All Repos icon — where its higher
specificity wins deterministically.

Verified with prettier, svelte-check (0 errors, 0 warnings) and 648
frontend tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Moving a branch already announces itself: the dialog closes and the
branch card leaves the source project's list as both ends refresh. The
"Branch moved to …" success toast on top of that was requested removed,
so handleMoveBranch now just refreshes both projects and invalidates
the timeline. Failures still surface in the dialog's error line.

Verified with prettier, svelte-check (0 errors) and 648 frontend tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ed0b95efd9

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

// sessions whose link to the branch runs through something this
// query would otherwise have to enumerate. The suffix is preserved
// so a session rooted at a repo subpath keeps it.
let old_prefix = format!("{}/", wd.old_path);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use platform separators when rewriting session paths

On Windows, sessions.working_dir is stored from Path::to_string_lossy(), so subdirectories under a worktree use \ separators. This prefix only matches <old>/, meaning sessions rooted under a moved worktree (for example a repo subpath session) are not rewritten by the instr(working_dir, ?5) branch and will continue pointing at the old path after the worktree and workdirs row move. Build the prefix with the platform separator or use path-aware matching before updating the stored working dirs.

Useful? React with 👍 / 👎.

Resolves the Codex P2 on the branch move's session rewrite.

move_branch_to_project re-roots sessions.working_dir by matching a
hard-coded "{old_path}/" prefix, but working dirs are stored via
Path::to_string_lossy, so on Windows the separator PathBuf::join
inserts after the worktree root is a backslash. A session rooted at a
repo subpath there never matched, and kept pointing into the old
worktree after the workdirs row and the tree itself had moved.

The query now takes its prefixes from a tested helper: on Windows both
"{old_path}\" for joined paths and "{old_path}/" for paths that
arrived as strings through the web router; on Unix the pair collapses
to "/" alone, because a backslash is an ordinary filename character
there and matching it would move a sibling directory that merely has
one in its name — a test pins that sibling in place. The
suffix-preserving substr needs no change: it starts at the separator
character itself, so a rewritten path keeps whichever separator it was
recorded with.

Verified with cargo fmt, cargo clippy -D warnings, and 731 Rust tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant