Skip to content

Commit 3c2d9a2

Browse files
authored
feat(worktree): ✨ Fix branch tracking and support remote branch checkout (#157)
* feat(worktree): ✨ support checking out remote branches with upstream tracking Add the ability to create a worktree from a remote branch by automatically creating a local tracking branch. Previously, users could only select local branches in the worktree dialog, limiting the ability to work on un-checked-out remote branches. Key changes include: - Expose a `trackUpstream` field in the worktree creation input model to control whether the new branch tracks the base ref as its upstream; defaults to `false` for feature branches - Respect `trackUpstream` in the backend worktree add command by passing `--no-track` when the flag is `false` - In the UI dialog, compute remote branches that have no local counterpart and display them in a dedicated section labeled "remote" alongside local branches - When a remote branch is selected, construct a create-branch input with `trackUpstream: true` so the local branch automatically tracks the remote ref, enabling `git pull` to work out of the box This allows developers to spin up a worktree from any remote branch (e.g., a PR branch) without needing to manually create a local branch beforehand. * test(worktree): ✅ add track_upstream parameter to test fixtures
1 parent 5c30377 commit 3c2d9a2

5 files changed

Lines changed: 112 additions & 27 deletions

File tree

src-tauri/src/core/worktree_manager.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ impl WorktreeManager {
163163
.filter(|s| !s.is_empty())
164164
.map(str::to_string);
165165
let create_branch = input.create_branch;
166+
let track_upstream = input.track_upstream;
166167

167168
task::spawn_blocking(move || {
168169
run_git_worktree_add(
@@ -172,6 +173,7 @@ impl WorktreeManager {
172173
&branch_name,
173174
create_branch,
174175
base_ref.as_deref(),
176+
track_upstream,
175177
)
176178
})
177179
.await
@@ -548,6 +550,7 @@ fn run_git_worktree_add(
548550
branch: &str,
549551
create_branch: bool,
550552
base_ref: Option<&str>,
553+
track_upstream: bool,
551554
) -> Result<(), AppError> {
552555
// `git worktree add` uses the final path component as the worktree
553556
// registration name. We don't force it explicitly; the caller is expected
@@ -560,6 +563,13 @@ fn run_git_worktree_add(
560563
if create_branch {
561564
args.push("-b".to_string());
562565
args.push(branch.to_string());
566+
// When track_upstream is false, prevent the new branch from tracking
567+
// the base ref so that `git push` won't accidentally target the base
568+
// branch's upstream. When track_upstream is true (e.g. checking out a
569+
// remote branch locally), we let Git set up tracking normally.
570+
if base_ref.is_some() && !track_upstream {
571+
args.push("--no-track".to_string());
572+
}
563573
args.push(target_path.to_string());
564574
if let Some(base) = base_ref {
565575
args.push(base.to_string());

src-tauri/src/model/workspace.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,12 @@ pub struct WorktreeCreateInput {
160160
/// When true, a new local branch is created and checked out in the worktree.
161161
#[serde(default)]
162162
pub create_branch: bool,
163+
/// When true AND `create_branch` is true with a `base_ref`, the new branch
164+
/// will track the base ref as its upstream (useful when checking out an
165+
/// existing remote branch locally). Defaults to false so that brand-new
166+
/// feature branches do not accidentally track the base.
167+
#[serde(default)]
168+
pub track_upstream: bool,
163169
/// Optional custom path for the new worktree directory. When None the
164170
/// manager derives `<repo parent>/<repo name>-<branch-slug>`.
165171
#[serde(default)]

src-tauri/tests/worktree.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ async fn worktree_create_registers_workspace_and_updates_parent_kind() {
103103
branch: "feature/worktree-alpha".to_string(),
104104
base_ref: None,
105105
create_branch: true,
106+
track_upstream: false,
106107
path: None,
107108
},
108109
)
@@ -163,6 +164,7 @@ async fn worktree_create_rejects_non_repo_parent() {
163164
branch: "feature/foo".to_string(),
164165
base_ref: None,
165166
create_branch: true,
167+
track_upstream: false,
166168
path: None,
167169
},
168170
)
@@ -203,6 +205,7 @@ async fn worktree_remove_cleans_up_admin_dir_and_db_row() {
203205
branch: "feature/remove-me".to_string(),
204206
base_ref: None,
205207
create_branch: true,
208+
track_upstream: false,
206209
path: None,
207210
},
208211
)
@@ -268,6 +271,7 @@ async fn worktree_parent_remove_cascades_children() {
268271
branch: "feature/child".to_string(),
269272
base_ref: None,
270273
create_branch: true,
274+
track_upstream: false,
271275
path: None,
272276
},
273277
)
@@ -337,6 +341,7 @@ async fn set_default_rejects_worktree_row() {
337341
branch: "feature/no-default".to_string(),
338342
base_ref: None,
339343
create_branch: true,
344+
track_upstream: false,
340345
path: None,
341346
},
342347
)

src/modules/workbench-shell/ui/new-worktree-dialog.tsx

Lines changed: 90 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,18 @@ export function NewWorktreeDialog({
111111
[branches],
112112
);
113113

114+
// Remote branches that don't already have a local counterpart.
115+
const remoteBranchesWithoutLocal = useMemo(() => {
116+
const localNames = new Set(localBranches.map((b) => b.name));
117+
return branches.filter((b) => {
118+
if (!b.isRemote) return false;
119+
// Strip the remote prefix (e.g. "origin/feature-x" → "feature-x")
120+
const slashIdx = b.name.indexOf("/");
121+
const localName = slashIdx >= 0 ? b.name.slice(slashIdx + 1) : b.name;
122+
return !localNames.has(localName);
123+
});
124+
}, [branches, localBranches]);
125+
114126
const canSubmit =
115127
mode === "newBranch"
116128
? Boolean(context && branch.trim() && !submitting)
@@ -150,11 +162,34 @@ export function NewWorktreeDialog({
150162
setError(null);
151163
setSubmitting(true);
152164
try {
153-
const input: WorktreeCreateInput = {
154-
branch: selectedExistingBranch,
155-
createBranch: false,
156-
path: path.trim() || undefined,
157-
};
165+
// Check if the selected branch is a remote ref.
166+
const isRemote = remoteBranchesWithoutLocal.some(
167+
(b) => b.name === selectedExistingBranch,
168+
);
169+
170+
let input: WorktreeCreateInput;
171+
if (isRemote) {
172+
// For remote branches, create a local tracking branch from the remote ref.
173+
const slashIdx = selectedExistingBranch.indexOf("/");
174+
const localName =
175+
slashIdx >= 0
176+
? selectedExistingBranch.slice(slashIdx + 1)
177+
: selectedExistingBranch;
178+
input = {
179+
branch: localName,
180+
createBranch: true,
181+
baseRef: selectedExistingBranch,
182+
trackUpstream: true,
183+
path: path.trim() || undefined,
184+
};
185+
} else {
186+
input = {
187+
branch: selectedExistingBranch,
188+
createBranch: false,
189+
path: path.trim() || undefined,
190+
};
191+
}
192+
158193
const created = await workspaceCreateWorktree(context.repo.id, input);
159194
onCreated?.(created);
160195
onClose();
@@ -170,6 +205,7 @@ export function NewWorktreeDialog({
170205
branch,
171206
baseBranch,
172207
selectedExistingBranch,
208+
remoteBranchesWithoutLocal,
173209
path,
174210
t,
175211
onCreated,
@@ -291,7 +327,7 @@ export function NewWorktreeDialog({
291327
</>
292328
) : (
293329
<>
294-
{/* Existing branch selector (local only) */}
330+
{/* Existing branch selector (local + unchecked-out remote) */}
295331
<div className="flex flex-col gap-1.5">
296332
<label className="text-sm font-medium">{t("worktree.field.selectBranch")}</label>
297333
<div className="flex max-h-48 flex-col gap-1 overflow-y-auto rounded border bg-muted/20 p-1">
@@ -300,32 +336,59 @@ export function NewWorktreeDialog({
300336
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
301337
{t("worktree.loading.branches")}
302338
</div>
303-
) : localBranches.length === 0 ? (
339+
) : localBranches.length === 0 &&
340+
remoteBranchesWithoutLocal.length === 0 ? (
304341
<div className="px-2 py-4 text-sm text-muted-foreground">
305342
{t("worktree.empty.branches")}
306343
</div>
307344
) : (
308-
localBranches.map((b) => {
309-
const isActive = selectedExistingBranch === b.name;
310-
return (
311-
<button
312-
key={b.name}
313-
type="button"
314-
onClick={() => setSelectedExistingBranch(b.name)}
315-
className={cn(
316-
"flex items-center justify-between rounded px-2 py-1 text-left text-sm hover:bg-background",
317-
isActive && "bg-background font-medium",
318-
)}
319-
>
320-
<span className="truncate">{b.name}</span>
321-
{b.isHead && (
322-
<span className="ml-2 rounded bg-muted px-1 text-[10px] font-medium text-muted-foreground">
323-
HEAD
345+
<>
346+
{localBranches.map((b) => {
347+
const isActive = selectedExistingBranch === b.name;
348+
return (
349+
<button
350+
key={b.name}
351+
type="button"
352+
onClick={() => setSelectedExistingBranch(b.name)}
353+
className={cn(
354+
"flex items-center justify-between rounded px-2 py-1 text-left text-sm hover:bg-background",
355+
isActive && "bg-background font-medium",
356+
)}
357+
>
358+
<span className="truncate">{b.name}</span>
359+
<span className="ml-2 flex items-center gap-1">
360+
{b.isHead && (
361+
<span className="rounded bg-muted px-1 text-[10px] font-medium text-muted-foreground">
362+
HEAD
363+
</span>
364+
)}
365+
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
366+
local
367+
</span>
324368
</span>
325-
)}
326-
</button>
327-
);
328-
})
369+
</button>
370+
);
371+
})}
372+
{remoteBranchesWithoutLocal.map((b) => {
373+
const isActive = selectedExistingBranch === b.name;
374+
return (
375+
<button
376+
key={`remote:${b.name}`}
377+
type="button"
378+
onClick={() => setSelectedExistingBranch(b.name)}
379+
className={cn(
380+
"flex items-center justify-between rounded px-2 py-1 text-left text-sm hover:bg-background",
381+
isActive && "bg-background font-medium",
382+
)}
383+
>
384+
<span className="truncate">{b.name}</span>
385+
<span className="ml-2 text-[10px] uppercase tracking-wider text-muted-foreground">
386+
remote
387+
</span>
388+
</button>
389+
);
390+
})}
391+
</>
329392
)}
330393
</div>
331394
{selectedExistingBranch && (

src/shared/types/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export interface WorktreeCreateInput {
4747
branch: string;
4848
baseRef?: string | null;
4949
createBranch?: boolean;
50+
trackUpstream?: boolean;
5051
path?: string | null;
5152
}
5253

0 commit comments

Comments
 (0)