@@ -10,7 +10,7 @@ use anyhow::{Context, Result};
1010#[ derive( Debug , Serialize , Deserialize , Clone , Copy , PartialEq , Eq ) ]
1111#[ serde( rename_all = "lowercase" ) ]
1212pub 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+ }
0 commit comments