git-gui: Show a progress meter for checking out files
[git] / lib / checkout_op.tcl
1 # git-gui commit checkout support
2 # Copyright (C) 2007 Shawn Pearce
3
4 class checkout_op {
5
6 field w        {}; # our window (if we have one)
7 field w_cons   {}; # embedded console window object
8
9 field new_expr   ; # expression the user saw/thinks this is
10 field new_hash   ; # commit SHA-1 we are switching to
11 field new_ref    ; # ref we are updating/creating
12
13 field parent_w      .; # window that started us
14 field merge_type none; # type of merge to apply to existing branch
15 field fetch_spec   {}; # refetch tracking branch if used?
16 field checkout      1; # actually checkout the branch?
17 field create        0; # create the branch if it doesn't exist?
18
19 field reset_ok      0; # did the user agree to reset?
20 field fetch_ok      0; # did the fetch succeed?
21
22 field readtree_d   {}; # buffered output from read-tree
23 field update_old   {}; # was the update-ref call deferred?
24 field reflog_msg   {}; # log message for the update-ref call
25
26 constructor new {expr hash {ref {}}} {
27         set new_expr $expr
28         set new_hash $hash
29         set new_ref  $ref
30
31         return $this
32 }
33
34 method parent {path} {
35         set parent_w [winfo toplevel $path]
36 }
37
38 method enable_merge {type} {
39         set merge_type $type
40 }
41
42 method enable_fetch {spec} {
43         set fetch_spec $spec
44 }
45
46 method enable_checkout {co} {
47         set checkout $co
48 }
49
50 method enable_create {co} {
51         set create $co
52 }
53
54 method run {} {
55         if {$fetch_spec ne {}} {
56                 global M1B
57
58                 # We were asked to refresh a single tracking branch
59                 # before we get to work.  We should do that before we
60                 # consider any ref updating.
61                 #
62                 set fetch_ok 0
63                 set l_trck [lindex $fetch_spec 0]
64                 set remote [lindex $fetch_spec 1]
65                 set r_head [lindex $fetch_spec 2]
66                 regsub ^refs/heads/ $r_head {} r_name
67
68                 _toplevel $this {Refreshing Tracking Branch}
69                 set w_cons [::console::embed \
70                         $w.console \
71                         "Fetching $r_name from $remote"]
72                 pack $w.console -fill both -expand 1
73                 $w_cons exec \
74                         [list git fetch $remote +$r_head:$l_trck] \
75                         [cb _finish_fetch]
76
77                 bind $w <$M1B-Key-w> break
78                 bind $w <$M1B-Key-W> break
79                 bind $w <Visibility> "
80                         [list grab $w]
81                         [list focus $w]
82                 "
83                 wm protocol $w WM_DELETE_WINDOW [cb _noop]
84                 tkwait window $w
85
86                 if {!$fetch_ok} {
87                         delete_this
88                         return 0
89                 }
90         }
91
92         if {$new_ref ne {}} {
93                 # If we have a ref we need to update it before we can
94                 # proceed with a checkout (if one was enabled).
95                 #
96                 if {![_update_ref $this]} {
97                         delete_this
98                         return 0
99                 }
100         }
101
102         if {$checkout} {
103                 _checkout $this
104                 return 1
105         }
106
107         delete_this
108         return 1
109 }
110
111 method _noop {} {}
112
113 method _finish_fetch {ok} {
114         if {$ok} {
115                 set l_trck [lindex $fetch_spec 0]
116                 if {[catch {set new_hash [git rev-parse --verify "$l_trck^0"]} err]} {
117                         set ok 0
118                         $w_cons insert "fatal: Cannot resolve $l_trck"
119                         $w_cons insert $err
120                 }
121         }
122
123         $w_cons done $ok
124         set w_cons {}
125         wm protocol $w WM_DELETE_WINDOW {}
126
127         if {$ok} {
128                 destroy $w
129                 set w {}
130         } else {
131                 button $w.close -text Close -command [list destroy $w]
132                 pack $w.close -side bottom -anchor e -padx 10 -pady 10
133         }
134
135         set fetch_ok $ok
136 }
137
138 method _update_ref {} {
139         global null_sha1 current_branch
140
141         set ref $new_ref
142         set new $new_hash
143
144         set is_current 0
145         set rh refs/heads/
146         set rn [string length $rh]
147         if {[string equal -length $rn $rh $ref]} {
148                 set newbranch [string range $ref $rn end]
149                 if {$current_branch eq $newbranch} {
150                         set is_current 1
151                 }
152         } else {
153                 set newbranch $ref
154         }
155
156         if {[catch {set cur [git rev-parse --verify "$ref^0"]}]} {
157                 # Assume it does not exist, and that is what the error was.
158                 #
159                 if {!$create} {
160                         _error $this "Branch '$newbranch' does not exist."
161                         return 0
162                 }
163
164                 set reflog_msg "branch: Created from $new_expr"
165                 set cur $null_sha1
166         } elseif {$create && $merge_type eq {none}} {
167                 # We were told to create it, but not do a merge.
168                 # Bad.  Name shouldn't have existed.
169                 #
170                 _error $this "Branch '$newbranch' already exists."
171                 return 0
172         } elseif {!$create && $merge_type eq {none}} {
173                 # We aren't creating, it exists and we don't merge.
174                 # We are probably just a simple branch switch.
175                 # Use whatever value we just read.
176                 #
177                 set new      $cur
178                 set new_hash $cur
179         } elseif {$new eq $cur} {
180                 # No merge would be required, don't compute anything.
181                 #
182         } else {
183                 set mrb {}
184                 catch {set mrb [git merge-base $new $cur]}
185                 switch -- $merge_type {
186                 ff {
187                         if {$mrb eq $new} {
188                                 # The current branch is actually newer.
189                                 #
190                                 set new $cur
191                         } elseif {$mrb eq $cur} {
192                                 # The current branch is older.
193                                 #
194                                 set reflog_msg "merge $new_expr: Fast-forward"
195                         } else {
196                                 _error $this "Branch '$newbranch' already exists.\n\nIt cannot fast-forward to $new_expr.\nA merge is required."
197                                 return 0
198                         }
199                 }
200                 reset {
201                         if {$mrb eq $cur} {
202                                 # The current branch is older.
203                                 #
204                                 set reflog_msg "merge $new_expr: Fast-forward"
205                         } else {
206                                 # The current branch will lose things.
207                                 #
208                                 if {[_confirm_reset $this $cur]} {
209                                         set reflog_msg "reset $new_expr"
210                                 } else {
211                                         return 0
212                                 }
213                         }
214                 }
215                 default {
216                         _error $this "Only 'ff' and 'reset' merge is currently supported."
217                         return 0
218                 }
219                 }
220         }
221
222         if {$new ne $cur} {
223                 if {$is_current} {
224                         # No so fast.  We should defer this in case
225                         # we cannot update the working directory.
226                         #
227                         set update_old $cur
228                         return 1
229                 }
230
231                 if {[catch {
232                                 git update-ref -m $reflog_msg $ref $new $cur
233                         } err]} {
234                         _error $this "Failed to update '$newbranch'.\n\n$err"
235                         return 0
236                 }
237         }
238
239         return 1
240 }
241
242 method _checkout {} {
243         if {[lock_index checkout_op]} {
244                 after idle [cb _start_checkout]
245         } else {
246                 _error $this "Index is already locked."
247                 delete_this
248         }
249 }
250
251 method _start_checkout {} {
252         global HEAD commit_type
253
254         # -- Our in memory state should match the repository.
255         #
256         repository_state curType curHEAD curMERGE_HEAD
257         if {[string match amend* $commit_type]
258                 && $curType eq {normal}
259                 && $curHEAD eq $HEAD} {
260         } elseif {$commit_type ne $curType || $HEAD ne $curHEAD} {
261                 info_popup {Last scanned state does not match repository state.
262
263 Another Git program has modified this repository since the last scan.  A rescan must be performed before the current branch can be changed.
264
265 The rescan will be automatically started now.
266 }
267                 unlock_index
268                 rescan ui_ready
269                 delete_this
270                 return
271         }
272
273         if {[is_config_true gui.trustmtime]} {
274                 _readtree $this
275         } else {
276                 ui_status {Refreshing file status...}
277                 set cmd [list git update-index]
278                 lappend cmd -q
279                 lappend cmd --unmerged
280                 lappend cmd --ignore-missing
281                 lappend cmd --refresh
282                 set fd [open "| $cmd" r]
283                 fconfigure $fd -blocking 0 -translation binary
284                 fileevent $fd readable [cb _refresh_wait $fd]
285         }
286 }
287
288 method _refresh_wait {fd} {
289         read $fd
290         if {[eof $fd]} {
291                 close $fd
292                 _readtree $this
293         }
294 }
295
296 method _name {} {
297         if {$new_ref eq {}} {
298                 return [string range $new_hash 0 7]
299         }
300
301         set rh refs/heads/
302         set rn [string length $rh]
303         if {[string equal -length $rn $rh $new_ref]} {
304                 return [string range $new_ref $rn end]
305         } else {
306                 return $new_ref
307         }
308 }
309
310 method _readtree {} {
311         global HEAD
312
313         set readtree_d {}
314         $::main_status start \
315                 "Updating working directory to '[_name $this]'..." \
316                 {files checked out}
317
318         set cmd [list git read-tree]
319         lappend cmd -m
320         lappend cmd -u
321         lappend cmd -v
322         lappend cmd --exclude-per-directory=.gitignore
323         lappend cmd $HEAD
324         lappend cmd $new_hash
325
326         if {[catch {
327                         set fd [open "| $cmd 2>@1" r]
328                 } err]} {
329                 # Older versions of Tcl 8.4 don't have this 2>@1 IO
330                 # redirect operator.  Fallback to |& cat for those.
331                 #
332                 set fd [open "| $cmd |& cat" r]
333         }
334
335         fconfigure $fd -blocking 0 -translation binary
336         fileevent $fd readable [cb _readtree_wait $fd]
337 }
338
339 method _readtree_wait {fd} {
340         global current_branch
341
342         set buf [read $fd]
343         $::main_status update_meter $buf
344         append readtree_d $buf
345
346         fconfigure $fd -blocking 1
347         if {![eof $fd]} {
348                 fconfigure $fd -blocking 0
349                 return
350         }
351
352         if {[catch {close $fd}]} {
353                 set err $readtree_d
354                 regsub {^fatal: } $err {} err
355                 $::main_status stop "Aborted checkout of '[_name $this]' (file level merging is required)."
356                 warn_popup "File level merge required.
357
358 $err
359
360 Staying on branch '$current_branch'."
361                 unlock_index
362                 delete_this
363                 return
364         }
365
366         $::main_status stop
367         _after_readtree $this
368 }
369
370 method _after_readtree {} {
371         global selected_commit_type commit_type HEAD MERGE_HEAD PARENT
372         global current_branch is_detached
373         global ui_comm
374
375         set name [_name $this]
376         set log "checkout: moving"
377         if {!$is_detached} {
378                 append log " from $current_branch"
379         }
380
381         # -- Move/create HEAD as a symbolic ref.  Core git does not
382         #    even check for failure here, it Just Works(tm).  If it
383         #    doesn't we are in some really ugly state that is difficult
384         #    to recover from within git-gui.
385         #
386         set rh refs/heads/
387         set rn [string length $rh]
388         if {[string equal -length $rn $rh $new_ref]} {
389                 set new_branch [string range $new_ref $rn end]
390                 append log " to $new_branch"
391
392                 if {[catch {
393                                 git symbolic-ref -m $log HEAD $new_ref
394                         } err]} {
395                         _fatal $this $err
396                 }
397                 set current_branch $new_branch
398                 set is_detached 0
399         } else {
400                 append log " to $new_expr"
401
402                 if {[catch {
403                                 _detach_HEAD $log $new_hash
404                         } err]} {
405                         _fatal $this $err
406                 }
407                 set current_branch HEAD
408                 set is_detached 1
409         }
410
411         # -- We had to defer updating the branch itself until we
412         #    knew the working directory would update.  So now we
413         #    need to finish that work.  If it fails we're in big
414         #    trouble.
415         #
416         if {$update_old ne {}} {
417                 if {[catch {
418                                 git update-ref \
419                                         -m $reflog_msg \
420                                         $new_ref \
421                                         $new_hash \
422                                         $update_old
423                         } err]} {
424                         _fatal $this $err
425                 }
426         }
427
428         if {$is_detached} {
429                 info_popup "You are no longer on a local branch.
430
431 If you wanted to be on a branch, create one now starting from 'This Detached Checkout'."
432         }
433
434         # -- Update our repository state.  If we were previously in
435         #    amend mode we need to toss the current buffer and do a
436         #    full rescan to update our file lists.  If we weren't in
437         #    amend mode our file lists are accurate and we can avoid
438         #    the rescan.
439         #
440         unlock_index
441         set selected_commit_type new
442         if {[string match amend* $commit_type]} {
443                 $ui_comm delete 0.0 end
444                 $ui_comm edit reset
445                 $ui_comm edit modified false
446                 rescan [list ui_status "Checked out '$name'."]
447         } else {
448                 repository_state commit_type HEAD MERGE_HEAD
449                 set PARENT $HEAD
450                 ui_status "Checked out '$name'."
451         }
452         delete_this
453 }
454
455 git-version proc _detach_HEAD {log new} {
456         >= 1.5.3 {
457                 git update-ref --no-deref -m $log HEAD $new
458         }
459         default {
460                 set p [gitdir HEAD]
461                 file delete $p
462                 set fd [open $p w]
463                 fconfigure $fd -translation lf -encoding utf-8
464                 puts $fd $new
465                 close $fd
466         }
467 }
468
469 method _confirm_reset {cur} {
470         set reset_ok 0
471         set name [_name $this]
472         set gitk [list do_gitk [list $cur ^$new_hash]]
473
474         _toplevel $this {Confirm Branch Reset}
475         pack [label $w.msg1 \
476                 -anchor w \
477                 -justify left \
478                 -text "Resetting '$name' to $new_expr will lose the following commits:" \
479                 ] -anchor w
480
481         set list $w.list.l
482         frame $w.list
483         text $list \
484                 -font font_diff \
485                 -width 80 \
486                 -height 10 \
487                 -wrap none \
488                 -xscrollcommand [list $w.list.sbx set] \
489                 -yscrollcommand [list $w.list.sby set]
490         scrollbar $w.list.sbx -orient h -command [list $list xview]
491         scrollbar $w.list.sby -orient v -command [list $list yview]
492         pack $w.list.sbx -fill x -side bottom
493         pack $w.list.sby -fill y -side right
494         pack $list -fill both -expand 1
495         pack $w.list -fill both -expand 1 -padx 5 -pady 5
496
497         pack [label $w.msg2 \
498                 -anchor w \
499                 -justify left \
500                 -text {Recovering lost commits may not be easy.} \
501                 ]
502         pack [label $w.msg3 \
503                 -anchor w \
504                 -justify left \
505                 -text "Reset '$name'?" \
506                 ]
507
508         frame $w.buttons
509         button $w.buttons.visualize \
510                 -text Visualize \
511                 -command $gitk
512         pack $w.buttons.visualize -side left
513         button $w.buttons.reset \
514                 -text Reset \
515                 -command "
516                         set @reset_ok 1
517                         destroy $w
518                 "
519         pack $w.buttons.reset -side right
520         button $w.buttons.cancel \
521                 -default active \
522                 -text Cancel \
523                 -command [list destroy $w]
524         pack $w.buttons.cancel -side right -padx 5
525         pack $w.buttons -side bottom -fill x -pady 10 -padx 10
526
527         set fd [open "| git rev-list --pretty=oneline $cur ^$new_hash" r]
528         while {[gets $fd line] > 0} {
529                 set abbr [string range $line 0 7]
530                 set subj [string range $line 41 end]
531                 $list insert end "$abbr  $subj\n"
532         }
533         close $fd
534         $list configure -state disabled
535
536         bind $w    <Key-v> $gitk
537         bind $w <Visibility> "
538                 grab $w
539                 focus $w.buttons.cancel
540         "
541         bind $w <Key-Return> [list destroy $w]
542         bind $w <Key-Escape> [list destroy $w]
543         tkwait window $w
544         return $reset_ok
545 }
546
547 method _error {msg} {
548         if {[winfo ismapped $parent_w]} {
549                 set p $parent_w
550         } else {
551                 set p .
552         }
553
554         tk_messageBox \
555                 -icon error \
556                 -type ok \
557                 -title [wm title $p] \
558                 -parent $p \
559                 -message $msg
560 }
561
562 method _toplevel {title} {
563         regsub -all {::} $this {__} w
564         set w .$w
565
566         if {[winfo ismapped $parent_w]} {
567                 set p $parent_w
568         } else {
569                 set p .
570         }
571
572         toplevel $w
573         wm title $w $title
574         wm geometry $w "+[winfo rootx $p]+[winfo rooty $p]"
575 }
576
577 method _fatal {err} {
578         error_popup "Failed to set current branch.
579
580 This working directory is only partially switched.  We successfully updated your files, but failed to update an internal Git file.
581
582 This should not have occurred.  [appname] will now close and give up.
583
584 $err"
585         exit 1
586 }
587
588 }