Skip to content

Commit a5e68e1

Browse files
committed
feat(git): add git pull with safe checkout
- Add git:pull handler with fetch + merge support - Use safe checkout to preserve uncommitted changes - Handle fast-forward, merge, and conflict scenarios - Add Conflict status to FileStatus enum - Add Pull button to ChangesPanel UI - Show conflict files with orange "!" indicator
1 parent 27e947e commit a5e68e1

5 files changed

Lines changed: 166 additions & 3 deletions

File tree

anycode-backend/src/handlers/git_handler.rs

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use anyhow::{Context, Result};
1010
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
1111
#[serde(rename_all = "lowercase")]
1212
pub enum FileStatus {
13-
Modified, Added, Deleted, Renamed,
13+
Modified, Added, Deleted, Renamed, Conflict,
1414
}
1515

1616
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -211,3 +211,124 @@ pub async fn handle_git_push(ack: AckSender, _state: State<AppState>) {
211211
info!("Received git:push");
212212
send_response(ack, git_push_impl());
213213
}
214+
215+
fn git_pull_impl() -> Result<Value> {
216+
let workdir = crate::utils::current_dir();
217+
let repo = Repository::discover(&workdir)?;
218+
219+
// Get remote and branch
220+
let mut remote = repo.find_remote("origin")?;
221+
let head = repo.head()?;
222+
let branch_name = head.shorthand()
223+
.context("Detached HEAD state")?;
224+
225+
// Fetch from remote
226+
let mut callbacks = git2::RemoteCallbacks::new();
227+
callbacks.credentials(|_url, username_from_url, _allowed_types| {
228+
git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
229+
});
230+
231+
let mut fetch_opts = git2::FetchOptions::new();
232+
fetch_opts.remote_callbacks(callbacks);
233+
234+
remote.fetch(&[branch_name], Some(&mut fetch_opts), None)?;
235+
236+
// Get the fetched commit
237+
let fetch_head = repo.find_reference("FETCH_HEAD")?;
238+
let remote_commit = repo.reference_to_annotated_commit(&fetch_head)?;
239+
240+
// Analyze what kind of merge we need
241+
let (analysis, _) = repo.merge_analysis(&[&remote_commit])?;
242+
243+
if analysis.is_up_to_date() {
244+
info!("Git pull: already up to date");
245+
return Ok(json!({ "status": "up_to_date" }));
246+
}
247+
248+
if analysis.is_fast_forward() {
249+
// Fast-forward: just move the branch pointer
250+
let refname = format!("refs/heads/{}", branch_name);
251+
let mut reference = repo.find_reference(&refname)?;
252+
reference.set_target(remote_commit.id(), "Fast-forward pull")?;
253+
254+
// SAFE checkout - preserves uncommitted changes, fails if conflict
255+
let checkout_result = repo.checkout_head(Some(
256+
git2::build::CheckoutBuilder::default()
257+
.safe() // Don't overwrite uncommitted changes!
258+
));
259+
260+
if let Err(e) = checkout_result {
261+
// Revert the reference change
262+
reference.set_target(head.target().unwrap(), "Revert failed pull")?;
263+
anyhow::bail!("Pull would overwrite uncommitted changes: {}", e);
264+
}
265+
266+
info!("Git pull: fast-forward to {}", remote_commit.id());
267+
return Ok(json!({ "status": "fast_forward" }));
268+
}
269+
270+
// Need to merge
271+
repo.merge(&[&remote_commit], None, None)?;
272+
273+
let mut index = repo.index()?;
274+
275+
if index.has_conflicts() {
276+
// Collect conflicting files
277+
let conflicts: Vec<String> = index.conflicts()?
278+
.filter_map(|c| c.ok())
279+
.filter_map(|c| {
280+
c.our.or(c.their).or(c.ancestor)
281+
})
282+
.filter_map(|entry| String::from_utf8(entry.path).ok())
283+
.collect();
284+
285+
// Write files with conflict markers to disk (safe - doesn't overwrite unrelated changes)
286+
let checkout_result = repo.checkout_index(None, Some(
287+
git2::build::CheckoutBuilder::default()
288+
.allow_conflicts(true)
289+
.conflict_style_merge(true)
290+
.safe() // Preserve other uncommitted changes
291+
));
292+
293+
if let Err(e) = checkout_result {
294+
repo.cleanup_state()?;
295+
anyhow::bail!("Failed to write conflict markers: {}", e);
296+
}
297+
298+
info!("Git pull: conflicts in {:?}", conflicts);
299+
return Ok(json!({
300+
"status": "conflict",
301+
"files": conflicts
302+
}));
303+
}
304+
305+
// No conflicts - create merge commit
306+
let tree_id = index.write_tree()?;
307+
let tree = repo.find_tree(tree_id)?;
308+
309+
let sig = repo.signature().or_else(|_| {
310+
git2::Signature::now("Anycode User", "user@anycode.dev")
311+
})?;
312+
313+
let local_commit = head.peel_to_commit()?;
314+
let remote_commit_obj = repo.find_commit(remote_commit.id())?;
315+
316+
repo.commit(
317+
Some("HEAD"),
318+
&sig,
319+
&sig,
320+
&format!("Merge remote-tracking branch 'origin/{}'", branch_name),
321+
&tree,
322+
&[&local_commit, &remote_commit_obj]
323+
)?;
324+
325+
repo.cleanup_state()?;
326+
327+
info!("Git pull: merged successfully");
328+
Ok(json!({ "status": "merged" }))
329+
}
330+
331+
pub async fn handle_git_pull(ack: AckSender, _state: State<AppState>) {
332+
info!("Received git:pull");
333+
send_response(ack, git_pull_impl());
334+
}

anycode-backend/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ async fn on_connect(socket: SocketRef, state: State<AppState>) {
8686
socket.on("git:file-original", handle_git_file_original);
8787
socket.on("git:commit", handle_git_commit);
8888
socket.on("git:push", handle_git_push);
89+
socket.on("git:pull", handle_git_pull);
8990

9091
socket.on_disconnect(on_disconnect)
9192
}

anycode/App.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -853,6 +853,34 @@ const App: React.FC = () => {
853853
}
854854
}, [isConnected, fetchGitStatus]);
855855

856+
const handleGitPull = useCallback(() => {
857+
console.log('handleGitPull');
858+
if (wsRef.current && isConnected) {
859+
wsRef.current.emit('git:pull', {}, (response: any) => {
860+
if (response.success) {
861+
console.log('Pull response:', response);
862+
const status = response.status;
863+
864+
if (status === 'up_to_date') {
865+
alert('Already up to date');
866+
} else if (status === 'fast_forward') {
867+
alert('Fast-forwarded');
868+
} else if (status === 'merged') {
869+
alert('Merged successfully');
870+
} else if (status === 'conflict') {
871+
const files = response.files || [];
872+
alert(`Merge conflicts in:\n${files.join('\n')}\n\nResolve conflicts and commit.`);
873+
}
874+
875+
fetchGitStatus();
876+
} else {
877+
alert('Pull failed: ' + response.error);
878+
console.error('Pull failed:', response.error);
879+
}
880+
});
881+
}
882+
}, [isConnected, fetchGitStatus]);
883+
856884

857885
const handleOpenFileResponse = (path: string, content: string, history: { changes: Change[], index: number }) => {
858886
const fileName = getFileName(path);
@@ -1780,6 +1808,7 @@ const App: React.FC = () => {
17801808
onRefresh={fetchGitStatus}
17811809
onCommit={handleGitCommit}
17821810
onPush={handleGitPush}
1811+
onPull={handleGitPull}
17831812
/>
17841813
);
17851814
case 'files':

anycode/components/ChangesPanel.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,11 @@
231231
color: #4285f4;
232232
}
233233

234+
.status-conflict {
235+
background-color: rgba(255, 152, 0, 0.25);
236+
color: #ff9800;
237+
}
238+
234239
.changes-file-info {
235240
display: flex;
236241
flex-direction: column;

anycode/components/ChangesPanel.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import './ChangesPanel.css';
33

44
export interface ChangedFile {
55
path: string;
6-
status: 'modified' | 'added' | 'deleted' | 'renamed';
6+
status: 'modified' | 'added' | 'deleted' | 'renamed' | 'conflict';
77
}
88

99
interface ChangesPanelProps {
@@ -13,20 +13,23 @@ interface ChangesPanelProps {
1313
onRefresh: () => void;
1414
onCommit: (files: string[], message: string) => void;
1515
onPush: () => void;
16+
onPull: () => void;
1617
}
1718

1819
const statusIcons: Record<string, string> = {
1920
modified: 'M',
2021
added: 'A',
2122
deleted: 'D',
2223
renamed: 'R',
24+
conflict: '!',
2325
};
2426

2527
const statusColors: Record<string, string> = {
2628
modified: 'status-modified',
2729
added: 'status-added',
2830
deleted: 'status-deleted',
2931
renamed: 'status-renamed',
32+
conflict: 'status-conflict',
3033
};
3134

3235
const getFileName = (path: string): string => {
@@ -46,7 +49,8 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
4649
onFileClick,
4750
onRefresh,
4851
onCommit,
49-
onPush
52+
onPush,
53+
onPull
5054
}) => {
5155
const [message, setMessage] = useState('');
5256
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
@@ -131,6 +135,9 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
131135
>
132136
Commit
133137
</button>
138+
<button className="changes-action-btn" onClick={onPull} title="Pull">
139+
Pull
140+
</button>
134141
<button className="changes-action-btn" onClick={onPush} title="Push">
135142
Push
136143
</button>

0 commit comments

Comments
 (0)