Skip to content

Commit 8b22110

Browse files
committed
feat: add branch selector with safe checkout guard
1 parent 8bd0753 commit 8b22110

7 files changed

Lines changed: 218 additions & 6 deletions

File tree

anycode-backend/src/git.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,12 @@ impl PullResult {
9696
}
9797
}
9898

99+
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
100+
pub struct GitBranchInfo {
101+
pub name: String,
102+
pub is_current: bool,
103+
}
104+
99105
pub struct GitManager {
100106
workdir: PathBuf,
101107
status_cache: GitStatus,
@@ -440,6 +446,57 @@ impl GitManager {
440446
Ok(())
441447
}
442448

449+
pub fn list_branches(&self) -> Result<Vec<GitBranchInfo>> {
450+
let repo = self.repo()?;
451+
let current_branch = Self::branch_name(&repo);
452+
let mut branches = Vec::new();
453+
454+
for branch_result in repo.branches(Some(git2::BranchType::Local))? {
455+
let (branch, _) = branch_result?;
456+
if let Some(name) = branch.name()? {
457+
branches.push(GitBranchInfo {
458+
name: name.to_string(),
459+
is_current: name == current_branch,
460+
});
461+
}
462+
}
463+
464+
branches.sort_by(|a, b| a.name.cmp(&b.name));
465+
Ok(branches)
466+
}
467+
468+
pub fn checkout_branch(&self, branch: &str) -> Result<()> {
469+
let repo = self.repo()?;
470+
let mut status_opts = StatusOptions::new();
471+
status_opts
472+
.include_untracked(true)
473+
.recurse_untracked_dirs(true)
474+
.include_ignored(false);
475+
let statuses = repo.statuses(Some(&mut status_opts))?;
476+
if !statuses.is_empty() {
477+
anyhow::bail!(
478+
"Failed to change branch\nGit command failed:\nYou have local changes. Please commit your changes or stash them before you switch branches."
479+
);
480+
}
481+
482+
let local_branch = repo
483+
.find_branch(branch, git2::BranchType::Local)
484+
.with_context(|| format!("Local branch '{}' not found", branch))?;
485+
let reference = local_branch.into_reference();
486+
let reference_name = reference
487+
.name()
488+
.context("Invalid branch reference name")?
489+
.to_string();
490+
491+
repo.set_head(&reference_name)
492+
.with_context(|| format!("Failed to set HEAD to '{}'", branch))?;
493+
repo.checkout_head(Some(git2::build::CheckoutBuilder::new().safe()))
494+
.with_context(|| format!("Failed to change branch to '{}'", branch))?;
495+
496+
info!("Checked out branch {}", branch);
497+
Ok(())
498+
}
499+
443500
/// Pull from remote
444501
pub fn pull(&self) -> Result<PullResult> {
445502
let repo = self.repo()?;

anycode-backend/src/handlers/git_handler.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ pub struct GitRevertRequest {
2020
pub path: String,
2121
}
2222

23+
#[derive(Debug, Serialize, Deserialize, Clone)]
24+
pub struct GitCheckoutRequest {
25+
pub branch: String,
26+
}
27+
2328
pub async fn handle_git_status(ack: AckSender, state: State<AppState>) {
2429
info!("Received git:status");
2530
let result = {
@@ -79,6 +84,28 @@ pub async fn handle_git_pull(ack: AckSender, state: State<AppState>) {
7984
send_response(ack, result);
8085
}
8186

87+
pub async fn handle_git_branches(ack: AckSender, state: State<AppState>) {
88+
info!("Received git:branches");
89+
let result = {
90+
let git = state.git_manager.lock().await;
91+
git.list_branches().map(|branches| json!({ "branches": branches }))
92+
};
93+
send_response(ack, result);
94+
}
95+
96+
pub async fn handle_git_checkout(
97+
Data(request): Data<GitCheckoutRequest>,
98+
ack: AckSender,
99+
state: State<AppState>,
100+
) {
101+
info!("Received git:checkout: {}", request.branch);
102+
let result = {
103+
let git = state.git_manager.lock().await;
104+
git.checkout_branch(&request.branch).map(|_| json!({}))
105+
};
106+
send_response(ack, result);
107+
}
108+
82109
pub async fn handle_git_revert(
83110
Data(request): Data<GitRevertRequest>,
84111
ack: AckSender,

anycode-backend/src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ async fn on_connect(socket: SocketRef, _state: State<AppState>) {
8888
socket.on("git:commit", handle_git_commit);
8989
socket.on("git:push", handle_git_push);
9090
socket.on("git:pull", handle_git_pull);
91+
socket.on("git:branches", handle_git_branches);
92+
socket.on("git:checkout", handle_git_checkout);
9193
socket.on("git:revert", handle_git_revert);
9294

9395
socket.on_disconnect(on_disconnect)

anycode/App.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,10 @@ const App: React.FC = () => {
120120
terminals.reconnectTerminals();
121121
agents.reconnectToAcpAgents();
122122
git.fetchGitStatus();
123+
git.fetchBranches();
123124
}
124125
wasConnectedRef.current = isConnected;
125-
}, [isConnected, openFolder, terminals.reconnectTerminals, agents.reconnectToAcpAgents, git.fetchGitStatus]);
126+
}, [isConnected, openFolder, terminals.reconnectTerminals, agents.reconnectToAcpAgents, git.fetchGitStatus, git.fetchBranches]);
126127

127128
useEffect(() => {
128129
return () => {
@@ -362,8 +363,11 @@ const App: React.FC = () => {
362363
<ChangesPanel
363364
files={git.changedFiles}
364365
branch={git.gitBranch}
366+
branches={git.branches}
367+
isSwitchingBranch={git.isSwitchingBranch}
365368
onFileClick={handleOpenFileDiff}
366369
onRefresh={git.fetchGitStatus}
370+
onBranchChange={git.checkoutBranch}
367371
onCommit={git.commit}
368372
onPush={git.push}
369373
onPull={git.pull}
@@ -435,6 +439,7 @@ const App: React.FC = () => {
435439

436440
if (panelId === 'changes') {
437441
git.fetchGitStatus();
442+
git.fetchBranches();
438443
return;
439444
}
440445
if (panelId === 'editor') {
@@ -449,7 +454,7 @@ const App: React.FC = () => {
449454
if (panelId === 'terminal') {
450455
terminalPanes.registerPane(panelKey);
451456
}
452-
}, [agentPanes, editors, git.fetchGitStatus, layout, terminalPanes]);
457+
}, [agentPanes, editors, git.fetchGitStatus, git.fetchBranches, layout, terminalPanes]);
453458

454459
const handlePanelRemoved = useCallback((panelId: PanelId, panelKey: string) => {
455460
layout.handlePanelRemoved(panelId, panelKey);

anycode/components/ChangesPanel.css

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
overflow: scroll;
2121
align-items: center;
2222
justify-content: space-between;
23-
padding: 6px 6px;
23+
padding: 10px;
2424
}
2525

2626
.changes-actions-right {
@@ -35,14 +35,56 @@
3535
}
3636

3737
.changes-branch-icon {
38+
display: inline-flex;
39+
align-items: center;
40+
justify-content: center;
41+
width: 14px;
42+
height: 14px;
3843
color: #888;
39-
font-size: 14px;
44+
}
45+
46+
.changes-branch-icon svg {
47+
width: 14px;
48+
height: 14px;
49+
display: block;
4050
}
4151

4252
.changes-branch {
4353
font-weight: 500;
4454
}
4555

56+
.changes-branch-select {
57+
display: inline-block;
58+
width: fit-content;
59+
max-width: 180px;
60+
min-width: 0;
61+
flex: 0 0 auto;
62+
field-sizing: content;
63+
appearance: none;
64+
-webkit-appearance: none;
65+
border: none;
66+
background-color: transparent;
67+
color: #e0e0e0;
68+
font-size: 12px;
69+
border-radius: 0;
70+
padding: 2px 18px 2px 0;
71+
min-height: 24px;
72+
outline: none;
73+
cursor: pointer;
74+
white-space: nowrap;
75+
text-overflow: ellipsis;
76+
overflow: hidden;
77+
}
78+
79+
.changes-branch-select:disabled {
80+
opacity: 0.7;
81+
cursor: not-allowed;
82+
}
83+
84+
.changes-branch-select:hover:not(:disabled) {
85+
background-color: transparent;
86+
}
87+
4688
.changes-action-btn {
4789
background: none;
4890
border: 1px solid transparent;
@@ -82,6 +124,7 @@
82124

83125
.changes-refresh-btn {
84126
font-size: 16px;
127+
font-size: 16px;
85128
}
86129

87130
.changes-message-container {

anycode/components/ChangesPanel.tsx

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,11 @@ export interface ChangedFile {
1414
interface ChangesPanelProps {
1515
files: ChangedFile[];
1616
branch: string;
17+
branches: { name: string; is_current: boolean }[];
18+
isSwitchingBranch: boolean;
1719
onFileClick: (path: string) => void;
1820
onRefresh: () => void;
21+
onBranchChange: (branch: string) => Promise<boolean>;
1922
onCommit: (files: string[], message: string) => Promise<boolean>;
2023
onPush: () => void;
2124
onPull: () => void;
@@ -39,8 +42,11 @@ const getDisplayName = (path: string): string => {
3942
export const ChangesPanel: React.FC<ChangesPanelProps> = ({
4043
files,
4144
branch,
45+
branches,
46+
isSwitchingBranch,
4247
onFileClick,
4348
onRefresh,
49+
onBranchChange,
4450
onCommit,
4551
onPush,
4652
onPull,
@@ -134,6 +140,15 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
134140
}
135141
};
136142

143+
const handleBranchChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
144+
const nextBranch = e.target.value;
145+
if (!nextBranch || nextBranch === branch) {
146+
return;
147+
}
148+
await onBranchChange(nextBranch);
149+
};
150+
const isCurrentBranchInList = branches.some((item) => item.name === branch);
151+
137152
return (
138153
<div className="changes-panel">
139154
{/*<div className="changes-panel-title">Changes</div>*/}
@@ -152,8 +167,26 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
152167

153168
<div className="changes-header">
154169
<div className="changes-title">
155-
<span className="changes-branch-icon"></span>
156-
<span className="changes-branch">{branch || 'HEAD'}</span>
170+
<span className="changes-branch-icon"><Icons.Git /></span>
171+
<select
172+
className="changes-branch-select"
173+
value={isCurrentBranchInList ? branch : ''}
174+
onChange={handleBranchChange}
175+
disabled={isSwitchingBranch || branches.length === 0}
176+
title={isSwitchingBranch ? 'Switching branch...' : 'Select branch'}
177+
aria-label="Select branch"
178+
>
179+
{branches.length === 0 || !isCurrentBranchInList ? (
180+
<option value="">{branch || 'HEAD'}</option>
181+
) : null}
182+
{branches.length > 0 ? (
183+
branches.map((item) => (
184+
<option key={item.name} value={item.name}>
185+
{item.name}
186+
</option>
187+
))
188+
) : null}
189+
</select>
157190
</div>
158191
<div className="changes-actions-right">
159192
<button

anycode/hooks/useGit.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,16 @@ type GitStatusPatchUpdate = {
2828

2929
type GitStatusUpdate = GitStatusFullUpdate | GitStatusPatchUpdate;
3030

31+
type GitBranch = {
32+
name: string;
33+
is_current: boolean;
34+
};
35+
3136
export const useGit = ({ wsRef, isConnected }: UseGitParams) => {
3237
const [changedFiles, setChangedFiles] = useState<ChangedFile[]>([]);
3338
const [gitBranch, setGitBranch] = useState<string>('');
39+
const [branches, setBranches] = useState<GitBranch[]>([]);
40+
const [isSwitchingBranch, setIsSwitchingBranch] = useState(false);
3441

3542
const fetchGitStatus = useCallback(() => {
3643
if (!wsRef.current || !isConnected) return;
@@ -46,6 +53,18 @@ export const useGit = ({ wsRef, isConnected }: UseGitParams) => {
4653
});
4754
}, [wsRef, isConnected]);
4855

56+
const fetchBranches = useCallback(() => {
57+
if (!wsRef.current || !isConnected) return;
58+
59+
wsRef.current.emit('git:branches', {}, (response: any) => {
60+
if (response.success) {
61+
setBranches(response.branches || []);
62+
} else {
63+
setBranches([]);
64+
}
65+
});
66+
}, [wsRef, isConnected]);
67+
4968
const handleGitStatusUpdate = useCallback((data: GitStatusUpdate) => {
5069
if (data.kind === 'patch') {
5170
setGitBranch(data.branch || '');
@@ -140,14 +159,40 @@ export const useGit = ({ wsRef, isConnected }: UseGitParams) => {
140159
});
141160
}, [wsRef, isConnected, fetchGitStatus]);
142161

162+
const checkoutBranch = useCallback((branch: string): Promise<boolean> => {
163+
return new Promise((resolve) => {
164+
if (!wsRef.current || !isConnected || !branch) {
165+
resolve(false);
166+
return;
167+
}
168+
169+
setIsSwitchingBranch(true);
170+
wsRef.current.emit('git:checkout', { branch }, (response: any) => {
171+
setIsSwitchingBranch(false);
172+
if (response.success) {
173+
fetchGitStatus();
174+
fetchBranches();
175+
resolve(true);
176+
} else {
177+
alert(response.error || 'Failed to change branch');
178+
resolve(false);
179+
}
180+
});
181+
});
182+
}, [wsRef, isConnected, fetchGitStatus, fetchBranches]);
183+
143184
return {
144185
changedFiles,
145186
gitBranch,
187+
branches,
188+
isSwitchingBranch,
146189
fetchGitStatus,
190+
fetchBranches,
147191
handleGitStatusUpdate,
148192
commit,
149193
push,
150194
pull,
151195
revert,
196+
checkoutBranch,
152197
};
153198
};

0 commit comments

Comments
 (0)