Skip to content

Commit 8f54004

Browse files
authored
feat(bookmarks): surface untracked remote bookmarks in Bookmark Manager (#56)
1 parent 3d76d2a commit 8f54004

8 files changed

Lines changed: 216 additions & 43 deletions

File tree

crates/jayjay-core/src/repo/bookmarks.rs

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::collections::BTreeSet;
1+
use std::collections::{BTreeMap, BTreeSet, HashSet};
22

33
use jj_lib::hex_util::encode_reverse_hex;
44
use jj_lib::object_id::ObjectId;
@@ -13,7 +13,9 @@ impl Repo {
1313
pub fn list_bookmarks(&self) -> CoreResult<Vec<BookmarkInfo>> {
1414
let repo = self.get_repo();
1515
let mut bookmarks = Vec::new();
16+
let mut local_names: HashSet<String> = HashSet::new();
1617
for (name, target) in repo.view().local_bookmarks() {
18+
local_names.insert(name.as_str().to_owned());
1719
let remote_refs: Vec<_> = repo
1820
.view()
1921
.all_remote_bookmarks()
@@ -33,35 +35,66 @@ impl Repo {
3335
.into_iter()
3436
.collect();
3537

36-
let is_conflicted = target.has_conflict();
37-
let is_deleted = target.is_absent();
38-
39-
let (change_id, description) = if let Some(commit_id) = target.as_normal() {
40-
match repo.store().get_commit(commit_id) {
41-
Ok(commit) => (
42-
encode_reverse_hex(commit.change_id().as_bytes()),
43-
commit.description().lines().next().unwrap_or("").to_owned(),
44-
),
45-
Err(_) => (String::new(), String::new()),
46-
}
47-
} else {
48-
(String::new(), String::new())
49-
};
50-
38+
let (change_id, description) = self.summary_at_target(target);
5139
bookmarks.push(BookmarkInfo {
5240
name: name.as_str().to_owned(),
5341
change_id,
5442
description,
5543
is_tracking_remote: !tracked_remotes.is_empty(),
56-
is_deleted,
57-
is_conflicted,
44+
is_deleted: target.is_absent(),
45+
is_conflicted: target.has_conflict(),
5846
tracked_remotes,
5947
available_remotes,
48+
has_local_target: true,
49+
});
50+
}
51+
52+
// Synthesize entries for remote bookmarks whose name has no local target.
53+
let mut orphans: BTreeMap<String, Vec<(String, RefTarget)>> = BTreeMap::new();
54+
for (sym, remote_ref) in repo.view().all_remote_bookmarks() {
55+
let name = sym.name.as_str();
56+
if local_names.contains(name) || remote_ref.target.is_absent() {
57+
continue;
58+
}
59+
orphans
60+
.entry(name.to_owned())
61+
.or_default()
62+
.push((sym.remote.as_str().to_owned(), remote_ref.target.clone()));
63+
}
64+
for (name, mut refs) in orphans {
65+
refs.sort_by(|a, b| a.0.cmp(&b.0));
66+
let remotes: Vec<String> = refs.iter().map(|(r, _)| r.clone()).collect();
67+
let first_target = &refs[0].1;
68+
let (change_id, description) = self.summary_at_target(first_target);
69+
bookmarks.push(BookmarkInfo {
70+
name,
71+
change_id,
72+
description,
73+
is_tracking_remote: false,
74+
is_deleted: false,
75+
is_conflicted: first_target.has_conflict(),
76+
tracked_remotes: Vec::new(),
77+
available_remotes: remotes,
78+
has_local_target: false,
6079
});
6180
}
81+
6282
Ok(bookmarks)
6383
}
6484

85+
fn summary_at_target(&self, target: &RefTarget) -> (String, String) {
86+
let Some(commit_id) = target.as_normal() else {
87+
return (String::new(), String::new());
88+
};
89+
match self.get_repo().store().get_commit(commit_id) {
90+
Ok(commit) => (
91+
encode_reverse_hex(commit.change_id().as_bytes()),
92+
commit.description().lines().next().unwrap_or("").to_owned(),
93+
),
94+
Err(_) => (String::new(), String::new()),
95+
}
96+
}
97+
6598
pub fn create_bookmark(&self, name: &str, rev: &str) -> CoreResult<()> {
6699
self.with_resolved_commit_transaction(
67100
rev,

crates/jayjay-core/src/types/bookmark.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,6 @@ pub struct BookmarkInfo {
88
pub is_conflicted: bool,
99
pub tracked_remotes: Vec<String>,
1010
pub available_remotes: Vec<String>,
11+
/// False for synthesized entries from an untracked remote bookmark (e.g. `feature@origin`).
12+
pub has_local_target: bool,
1113
}

crates/jayjay-core/tests/real_jj_repo.rs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -746,3 +746,92 @@ fn test_default_revset_not_empty() {
746746
"default revset should contain '@'"
747747
);
748748
}
749+
750+
#[test]
751+
fn list_bookmarks_includes_untracked_remote_branch() {
752+
// Alice pushes a branch to origin; bob clones and sees it as `name@origin` with no local target.
753+
let work_dir = tempfile::tempdir().expect("create work dir");
754+
let bare_path = work_dir.path().join("origin.git");
755+
let alice_path = work_dir.path().join("alice");
756+
let bob_path = work_dir.path().join("bob");
757+
758+
let bare_str = bare_path.to_str().expect("bare path utf-8");
759+
let alice_str = alice_path.to_str().expect("alice path utf-8");
760+
let bob_str = bob_path.to_str().expect("bob path utf-8");
761+
762+
// Bare origin
763+
run_command(
764+
"git",
765+
&["init".into(), "--bare".into(), bare_str.into()],
766+
Command::new("git").args(["init", "--bare", bare_str]),
767+
);
768+
769+
// Alice: colocated jj+git repo, two commits, push main and feature
770+
run_jj(&["git", "init", "--colocate", alice_str]);
771+
run_jj(&["-R", alice_str, "config", "set", "--repo", "user.name", "Alice"]);
772+
run_jj(&[
773+
"-R",
774+
alice_str,
775+
"config",
776+
"set",
777+
"--repo",
778+
"user.email",
779+
"alice@example.com",
780+
]);
781+
fs::write(alice_path.join("a.txt"), "alice initial\n").expect("write a.txt");
782+
run_jj(&["-R", alice_str, "describe", "-m", "initial"]);
783+
run_jj(&["-R", alice_str, "bookmark", "create", "main", "-r", "@"]);
784+
run_jj(&["-R", alice_str, "new", "-m", "alice feature work"]);
785+
fs::write(alice_path.join("b.txt"), "alice feature\n").expect("write b.txt");
786+
run_jj(&[
787+
"-R",
788+
alice_str,
789+
"bookmark",
790+
"create",
791+
"alice-feature",
792+
"-r",
793+
"@",
794+
]);
795+
run_git(&alice_path, &["remote", "add", "origin", bare_str]);
796+
run_jj(&[
797+
"-R",
798+
alice_str,
799+
"git",
800+
"push",
801+
"--bookmark",
802+
"main",
803+
"--bookmark",
804+
"alice-feature",
805+
"--remote",
806+
"origin",
807+
"--allow-new",
808+
]);
809+
810+
// Bob: clone via jj; alice-feature should arrive as an untracked remote bookmark
811+
run_jj(&["git", "clone", "--colocate", bare_str, bob_str]);
812+
813+
let repo = Repo::open(&bob_path).expect("open bob's repo");
814+
let bookmarks = repo.list_bookmarks().expect("list bookmarks");
815+
816+
let orphan = bookmarks
817+
.iter()
818+
.find(|b| b.name == "alice-feature")
819+
.expect("alice-feature should appear in list_bookmarks");
820+
assert!(
821+
!orphan.has_local_target,
822+
"alice-feature has no local target in bob's repo"
823+
);
824+
assert!(
825+
!orphan.is_tracking_remote,
826+
"alice-feature is not tracked in bob's repo"
827+
);
828+
assert_eq!(
829+
orphan.available_remotes,
830+
vec!["origin".to_string()],
831+
"alice-feature is available only on origin"
832+
);
833+
assert!(
834+
!orphan.change_id.is_empty(),
835+
"orphan entry should carry the change id from the remote target"
836+
);
837+
}

crates/jayjay-uniffi/src/types.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ pub struct BookmarkInfo {
122122
pub is_conflicted: bool,
123123
pub tracked_remotes: Vec<String>,
124124
pub available_remotes: Vec<String>,
125+
pub has_local_target: bool,
125126
}
126127

127128
#[uniffi::remote(Record)]

shell/gpui/src/log/menu.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,12 +133,12 @@ impl LogView {
133133
}
134134
let mut tracked: Vec<_> = bookmarks
135135
.iter()
136-
.filter(|b| b.is_tracking_remote)
136+
.filter(|b| b.has_local_target && b.is_tracking_remote)
137137
.cloned()
138138
.collect();
139139
let mut local: Vec<_> = bookmarks
140140
.iter()
141-
.filter(|b| !b.is_tracking_remote)
141+
.filter(|b| b.has_local_target && !b.is_tracking_remote)
142142
.cloned()
143143
.collect();
144144
tracked.sort_by(|a, b| a.name.cmp(&b.name));

shell/gpui/src/log/sidebar.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,11 @@ pub(super) fn sidebar(
144144
};
145145

146146
let bookmarks = view.vm.read(cx).graph.bookmarks.clone();
147-
let bookmark_bar_el = if bookmarks.is_empty() {
148-
None
149-
} else {
147+
let has_local = bookmarks.iter().any(|b| b.has_local_target);
148+
let bookmark_bar_el = if has_local {
150149
Some(bookmark_bar(bookmarks, t, cx))
150+
} else {
151+
None
151152
};
152153
let show_commit_box = view
153154
.vm
@@ -284,6 +285,9 @@ fn bookmark_bar(
284285
.overflow_x_scroll();
285286

286287
for bookmark in bookmarks.iter() {
288+
if !bookmark.has_local_target {
289+
continue;
290+
}
287291
let name = bookmark.name.clone();
288292
let target_change_id = bookmark.change_id.clone();
289293
let chip = div()

shell/mac/Sources/JayJay/Repo/BookmarkManagerView.swift

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ struct BookmarkManagerView: View {
3232
}
3333

3434
private var localOnlyCount: Int {
35-
bookmarks.filter { !$0.isTrackingRemote && !$0.isDeleted }.count
35+
bookmarks.filter { $0.hasLocalTarget && !$0.isTrackingRemote && !$0.isDeleted }.count
36+
}
37+
38+
private var remoteOnlyCount: Int {
39+
bookmarks.filter { !$0.hasLocalTarget }.count
3640
}
3741

3842
var body: some View {
@@ -81,6 +85,9 @@ struct BookmarkManagerView: View {
8185
if localOnlyCount > 0 {
8286
statBadge("\(localOnlyCount) local-only", color: .secondary)
8387
}
88+
if remoteOnlyCount > 0 {
89+
statBadge("\(remoteOnlyCount) remote-only", color: .blue)
90+
}
8491
Spacer()
8592
Toggle("Show deleted", isOn: $showDeleted)
8693
.jayjayFont(11)
@@ -102,6 +109,14 @@ struct BookmarkManagerView: View {
102109
.background(Color.primary.opacity(0.02))
103110
}
104111

112+
/// Untracked remotes only resolve as `name@remote` in revsets.
113+
private func revsetSymbol(for bookmark: BookmarkInfo) -> String {
114+
if !bookmark.hasLocalTarget, let remote = bookmark.availableRemotes.first {
115+
return "\(bookmark.name)@\(remote)"
116+
}
117+
return bookmark.name
118+
}
119+
105120
private func statBadge(_ text: String, color: Color) -> some View {
106121
Text(text)
107122
.jayjayFont(10, weight: .semibold)
@@ -118,15 +133,18 @@ struct BookmarkManagerView: View {
118133
ForEach(filteredBookmarks, id: \.name) { bookmark in
119134
BookmarkManagerRow(
120135
bookmark: bookmark,
121-
onFilter: { onFilter(bookmark.name) },
136+
onFilter: { onFilter(revsetSymbol(for: bookmark)) },
122137
onDelete: { actions?.deleteBookmark(name: bookmark.name) },
123138
onForget: { actions?.deleteBookmark(name: bookmark.name) },
124139
onPush: { actions?.gitPush(bookmark: bookmark.name) },
125140
onResolve: {
126141
try? repo?.moveBookmark(name: bookmark.name, toRev: "@-")
127142
actions?.gitFetch() // refresh
128143
},
129-
onOpenPR: { actions?.openPR(bookmark: bookmark.name) }
144+
onOpenPR: { actions?.openPR(bookmark: bookmark.name) },
145+
onTrack: { remote in
146+
actions?.trackBookmark(name: bookmark.name, remote: remote)
147+
}
130148
)
131149
}
132150
}
@@ -144,17 +162,25 @@ private struct BookmarkManagerRow: View {
144162
let onPush: () -> Void
145163
let onResolve: () -> Void
146164
let onOpenPR: () -> Void
165+
let onTrack: (String) -> Void
147166

148167
private var canOpenPR: Bool {
149168
bookmark.isTrackingRemote && !bookmark.isDeleted && !isTrunkBookmark(bookmark.name)
150169
}
151170

171+
private var remoteSuffix: String {
172+
guard !bookmark.hasLocalTarget, let first = bookmark.availableRemotes.first else {
173+
return ""
174+
}
175+
return "@\(first)"
176+
}
177+
152178
var body: some View {
153179
HStack(spacing: 10) {
154180
statusIcon
155181
VStack(alignment: .leading, spacing: 2) {
156182
HStack(spacing: 6) {
157-
Text(bookmark.name)
183+
Text(bookmark.name + remoteSuffix)
158184
.jayjayFont(13, weight: .medium, design: .monospaced)
159185
.lineLimit(1)
160186
if bookmark.isDeleted {
@@ -163,7 +189,9 @@ private struct BookmarkManagerRow: View {
163189
if bookmark.isConflicted {
164190
badge("conflicted", color: .orange)
165191
}
166-
if !bookmark.isTrackingRemote, !bookmark.isDeleted {
192+
if !bookmark.hasLocalTarget {
193+
badge("remote-only", color: .blue)
194+
} else if !bookmark.isTrackingRemote, !bookmark.isDeleted {
167195
badge("local", color: .secondary)
168196
}
169197
}
@@ -200,7 +228,13 @@ private struct BookmarkManagerRow: View {
200228
Label("Resolve conflict (set to @)", systemImage: "arrow.triangle.merge")
201229
}
202230
}
203-
if bookmark.isTrackingRemote, !bookmark.isDeleted {
231+
if !bookmark.hasLocalTarget {
232+
ForEach(bookmark.availableRemotes, id: \.self) { remote in
233+
Button { onTrack(remote) } label: {
234+
Label("Track \(bookmark.name)@\(remote)", systemImage: "arrow.down.circle")
235+
}
236+
}
237+
} else if bookmark.isTrackingRemote, !bookmark.isDeleted {
204238
Button { onPush() } label: {
205239
Label("Push", systemImage: "arrow.up.circle")
206240
}
@@ -210,14 +244,16 @@ private struct BookmarkManagerRow: View {
210244
Label("Pull Request on GitHub", systemImage: "arrow.up.right.square")
211245
}
212246
}
213-
Divider()
214-
if bookmark.isDeleted {
215-
Button { onForget() } label: {
216-
Label("Forget (remove from jj)", systemImage: "bookmark.slash")
217-
}
218-
} else {
219-
Button(role: .destructive) { onDelete() } label: {
220-
Label("Delete", systemImage: "trash")
247+
if bookmark.hasLocalTarget {
248+
Divider()
249+
if bookmark.isDeleted {
250+
Button { onForget() } label: {
251+
Label("Forget (remove from jj)", systemImage: "bookmark.slash")
252+
}
253+
} else {
254+
Button(role: .destructive) { onDelete() } label: {
255+
Label("Delete", systemImage: "trash")
256+
}
221257
}
222258
}
223259
}
@@ -231,6 +267,9 @@ private struct BookmarkManagerRow: View {
231267
} else if bookmark.isConflicted {
232268
Image(systemName: "exclamationmark.triangle.fill")
233269
.foregroundStyle(.orange)
270+
} else if !bookmark.hasLocalTarget {
271+
Image(systemName: "cloud")
272+
.foregroundStyle(.blue)
234273
} else if bookmark.isTrackingRemote {
235274
Image(systemName: "cloud.fill")
236275
.foregroundStyle(.blue)

0 commit comments

Comments
 (0)