Merge branch 'pu/pb/index_quick_fix' into stable
[GitX.git] / PBGitCommitController.m
blob3f156aacffcc10b4768a570a1d85bee6f998df10
1 //
2 //  PBGitCommitController.m
3 //  GitX
4 //
5 //  Created by Pieter de Bie on 19-09-08.
6 //  Copyright 2008 __MyCompanyName__. All rights reserved.
7 //
9 #import "PBGitCommitController.h"
10 #import "NSFileHandleExt.h"
11 #import "PBChangedFile.h"
12 #import "PBWebChangesController.h"
13 #import "NSString_RegEx.h"
14 #import "PBGitIndexController.h"
16 @interface PBGitCommitController (PrivateMethods)
17 - (NSArray *) linesFromNotification:(NSNotification *)notification;
18 - (void) doneProcessingIndex;
19 - (NSMutableDictionary *)dictionaryForLines:(NSArray *)lines;
20 - (void) addFilesFromDictionary:(NSMutableDictionary *)dictionary staged:(BOOL)staged tracked:(BOOL)tracked;
21 - (void)processHunk:(NSString *)hunk stage:(BOOL)stage reverse:(BOOL)reverse;
22 @end
24 @implementation PBGitCommitController
26 @synthesize files, status, busy, amend;
28 - (void)awakeFromNib
30         self.files = [NSMutableArray array];
31         [super awakeFromNib];
32         [self refresh:self];
34         [commitMessageView setTypingAttributes:[NSDictionary dictionaryWithObject:[NSFont fontWithName:@"Monaco" size:12.0] forKey:NSFontAttributeName]];
35         
36         [unstagedFilesController setFilterPredicate:[NSPredicate predicateWithFormat:@"hasUnstagedChanges == 1"]];
37         [cachedFilesController setFilterPredicate:[NSPredicate predicateWithFormat:@"hasStagedChanges == 1"]];
38         
39         [unstagedFilesController setSortDescriptors:[NSArray arrayWithObjects:
40                 [[NSSortDescriptor alloc] initWithKey:@"status" ascending:false],
41                 [[NSSortDescriptor alloc] initWithKey:@"path" ascending:true], nil]];
42         [cachedFilesController setSortDescriptors:[NSArray arrayWithObject:
43                 [[NSSortDescriptor alloc] initWithKey:@"path" ascending:true]]];
45 - (void) removeView
47         [webController closeView];
48         [super finalize];
50 - (NSResponder *)firstResponder;
52         return commitMessageView;
55 - (IBAction)signOff:(id)sender
57         if (![repository.config valueForKeyPath:@"user.name"] || ![repository.config valueForKeyPath:@"user.email"])
58                 return [[repository windowController] showMessageSheet:@"User's name not set" infoText:@"Signing off a commit requires setting user.name and user.email in your git config"];
59         NSString *SOBline = [NSString stringWithFormat:@"Signed-off-by: %@ <%@>",
60                                 [repository.config valueForKeyPath:@"user.name"],
61                                 [repository.config valueForKeyPath:@"user.email"]];
63         if([commitMessageView.string rangeOfString:SOBline].location == NSNotFound) {
64                 NSArray *selectedRanges = [commitMessageView selectedRanges];
65                 commitMessageView.string = [NSString stringWithFormat:@"%@\n\n%@",
66                                 commitMessageView.string, SOBline];
67                 [commitMessageView setSelectedRanges: selectedRanges];
68         }
71 - (void) setAmend:(BOOL)newAmend
73         if (newAmend == amend)
74                 return;
76         amend = newAmend;
77         amendEnvironment = nil;
79         // If we amend, we want to keep the author information for the previous commit
80         // We do this by reading in the previous commit, and storing the information
81         // in a dictionary. This dictionary will then later be read by [self commit:]
82         if (amend) {
83                 NSString *message = [repository outputForCommand:@"cat-file commit HEAD"];
84                 NSArray *match = [message substringsMatchingRegularExpression:@"\nauthor ([^\n]*) <([^\n>]*)> ([0-9]+[^\n]*)\n" count:3 options:0 ranges:nil error:nil];
85                 if (match)
86                         amendEnvironment = [NSDictionary dictionaryWithObjectsAndKeys:[match objectAtIndex:1], @"GIT_AUTHOR_NAME",
87                                 [match objectAtIndex:2], @"GIT_AUTHOR_EMAIL",
88                                 [match objectAtIndex:3], @"GIT_AUTHOR_DATE",
89                                  nil];
91                 // Replace commit message with the old one if it's less than 3 characters long.
92                 // This is just a random number.
93                 if ([[commitMessageView string] length] <= 3) {
94                         // Find the commit message
95                         NSRange r = [message rangeOfString:@"\n\n"];
96                         if (r.location != NSNotFound)
97                                 message = [message substringFromIndex:r.location + 2];
99                         commitMessageView.string = message;
100                 }
101         }
103         [self refresh:self];
106 - (NSArray *) linesFromNotification:(NSNotification *)notification
108         NSDictionary *userInfo = [notification userInfo];
109         NSData *data = [userInfo valueForKey:NSFileHandleNotificationDataItem];
110         if (!data)
111                 return NULL;
112         
113         NSString* string = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
114         if (!string)
115                 return NULL;
116         
117         // Strip trailing newline
118         if ([string hasSuffix:@"\n"])
119                 string = [string substringToIndex:[string length]-1];
120         
121         NSArray *lines = [string componentsSeparatedByString:@"\0"];
122         return lines;
125 - (NSString *) parentTree
127         NSString *parent = amend ? @"HEAD^" : @"HEAD";
129         if (![repository parseReference:parent])
130                 // We don't have a head ref. Return the empty tree.
131                 return @"4b825dc642cb6eb9a060e54bf8d69288fbee4904";
133         return parent;
136 - (void) refresh:(id) sender
138         if (![repository workingDirectory])
139                 return;
141         self.status = @"Refreshing index…";
143         // If self.busy reaches 0, all tasks have finished
144         self.busy = 0;
146         // Refresh the index, necessary for the next methods (that's why it's blocking)
147         // FIXME: Make this non-blocking. This call can be expensive in large repositories
148         [repository outputInWorkdirForArguments:[NSArray arrayWithObjects:@"update-index", @"-q", @"--unmerged", @"--ignore-missing", @"--refresh", nil]];
150         NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; 
151         [nc removeObserver:self]; 
153         // Other files (not tracked, not ignored)
154         NSArray *arguments = [NSArray arrayWithObjects:@"ls-files", @"--others", @"--exclude-standard", @"-z", nil];
155         NSFileHandle *handle = [repository handleInWorkDirForArguments:arguments];
156         [nc addObserver:self selector:@selector(readOtherFiles:) name:NSFileHandleReadToEndOfFileCompletionNotification object:handle]; 
157         self.busy++;
158         [handle readToEndOfFileInBackgroundAndNotify];
159         
160         // Unstaged files
161         handle = [repository handleInWorkDirForArguments:[NSArray arrayWithObjects:@"diff-files", @"-z", nil]];
162         [nc addObserver:self selector:@selector(readUnstagedFiles:) name:NSFileHandleReadToEndOfFileCompletionNotification object:handle]; 
163         self.busy++;
164         [handle readToEndOfFileInBackgroundAndNotify];
166         // Staged files
167         handle = [repository handleInWorkDirForArguments:[NSArray arrayWithObjects:@"diff-index", @"--cached", @"-z", [self parentTree], nil]];
168         [nc addObserver:self selector:@selector(readCachedFiles:) name:NSFileHandleReadToEndOfFileCompletionNotification object:handle]; 
169         self.busy++;
170         [handle readToEndOfFileInBackgroundAndNotify];
172         // Reload refs (in case HEAD changed)
173         [repository reloadRefs];
176 - (void) updateView
178         [self refresh:nil];
181 // This method is called for each of the three processes from above.
182 // If all three are finished (self.busy == 0), then we can delete
183 // all files previously marked as deletable
184 - (void) doneProcessingIndex
186         // if we're still busy, do nothing :)
187         if (--self.busy)
188                 return;
190         NSMutableArray *deleteFiles = [NSMutableArray array];
191         for (PBChangedFile *file in files) {
192                 if (!file.hasStagedChanges && !file.hasUnstagedChanges)
193                         [deleteFiles addObject:file];
194         }
196         if ([deleteFiles count]) {
197                 [self willChangeValueForKey:@"files"];
198                 for (PBChangedFile *file in deleteFiles)
199                         [files removeObject:file];
200                 [self didChangeValueForKey:@"files"];
201         }
202         self.status = @"Ready";
205 - (void) readOtherFiles:(NSNotification *)notification;
207         NSArray *lines = [self linesFromNotification:notification];
208         NSMutableDictionary *dictionary = [[NSMutableDictionary alloc] initWithCapacity:[lines count]];
209         // We fake this files status as good as possible.
210         NSArray *fileStatus = [NSArray arrayWithObjects:@":000000", @"100644", @"0000000000000000000000000000000000000000", @"0000000000000000000000000000000000000000", @"A", nil];
211         for (NSString *path in lines) {
212                 if ([path length] == 0)
213                         continue;
214                 [dictionary setObject:fileStatus forKey:path];
215         }
216         [self addFilesFromDictionary:dictionary staged:NO tracked:NO];
217         [self doneProcessingIndex];
220 - (NSMutableDictionary *)dictionaryForLines:(NSArray *)lines
222         NSMutableDictionary *dictionary = [NSMutableDictionary dictionaryWithCapacity:[lines count]/2];
224         // Fill the dictionary with the new information
225         NSArray *fileStatus;
226         BOOL even = FALSE;
227         for (NSString *line in lines) {
228                 if (!even) {
229                         even = TRUE;
230                         fileStatus = [line componentsSeparatedByString:@" "];
231                         continue;
232                 }
234                 even = FALSE;
235                 [dictionary setObject:fileStatus forKey:line];
236         }
237         return dictionary;
240 - (void) addFilesFromDictionary:(NSMutableDictionary *)dictionary staged:(BOOL)staged tracked:(BOOL)tracked
242         // Iterate over all existing files
243         [indexController stopTrackingIndex];
244         for (PBChangedFile *file in files) {
245                 NSArray *fileStatus = [dictionary objectForKey:file.path];
246                 // Object found, this is still a cached / uncached thing
247                 if (fileStatus) {
248                         if (tracked) {
249                                 NSString *mode = [[fileStatus objectAtIndex:0] substringFromIndex:1];
250                                 NSString *sha = [fileStatus objectAtIndex:2];
251                                 file.commitBlobSHA = sha;
252                                 file.commitBlobMode = mode;
254                                 if (staged)
255                                         file.hasStagedChanges = YES;
256                                 else
257                                         file.hasUnstagedChanges = YES;
258                         } else {
259                                 // Untracked file, set status to NEW, only unstaged changes
260                                 file.hasStagedChanges = NO;
261                                 file.hasUnstagedChanges = YES;
262                                 file.status = NEW;
263                         }
264                         [dictionary removeObjectForKey:file.path];
265                 } else { // Object not found, let's remove it from the changes
266                         if (staged)
267                                 file.hasStagedChanges = NO;
268                         else if (tracked && file.status != NEW) // Only remove it if it's not an untracked file. We handle that with the other thing
269                                 file.hasUnstagedChanges = NO;
270                         else if (!tracked && file.status == NEW)
271                                 file.hasUnstagedChanges = NO;
272                 }
273         }
274         [indexController resumeTrackingIndex];
276         // Do new files
277         if (![[dictionary allKeys] count])
278                 return;
280         [self willChangeValueForKey:@"files"];
281         for (NSString *path in [dictionary allKeys]) {
282                 NSArray *fileStatus = [dictionary objectForKey:path];
284                 PBChangedFile *file = [[PBChangedFile alloc] initWithPath:path];
285                 if ([[fileStatus objectAtIndex:4] isEqualToString:@"D"])
286                         file.status = DELETED;
287                 else if([[fileStatus objectAtIndex:0] isEqualToString:@":000000"])
288                         file.status = NEW;
289                 else
290                         file.status = MODIFIED;
292                 if (tracked) {
293                         file.commitBlobMode = [[fileStatus objectAtIndex:0] substringFromIndex:1];
294                         file.commitBlobSHA = [fileStatus objectAtIndex:2];
295                 }
297                 file.hasStagedChanges = staged;
298                 file.hasUnstagedChanges = !staged;
300                 [files addObject: file];
301         }
302         [self didChangeValueForKey:@"files"];
305 - (void) readUnstagedFiles:(NSNotification *)notification
307         NSArray *lines = [self linesFromNotification:notification];
308         NSMutableDictionary *dic = [self dictionaryForLines:lines];
309         [self addFilesFromDictionary:dic staged:NO tracked:YES];
310         [self doneProcessingIndex];
313 - (void) readCachedFiles:(NSNotification *)notification
315         NSArray *lines = [self linesFromNotification:notification];
316         NSMutableDictionary *dic = [self dictionaryForLines:lines];
317         [self addFilesFromDictionary:dic staged:YES tracked:YES];
318         [self doneProcessingIndex];
321 - (void) commitFailedBecause:(NSString *)reason
323         self.busy--;
324         self.status = [@"Commit failed: " stringByAppendingString:reason];
325         [[repository windowController] showMessageSheet:@"Commit failed" infoText:reason];
326         return;
329 - (IBAction) commit:(id) sender
331         if ([[NSFileManager defaultManager] fileExistsAtPath:[repository.fileURL.path stringByAppendingPathComponent:@"MERGE_HEAD"]]) {
332                 [[repository windowController] showMessageSheet:@"Cannot commit merges" infoText:@"GitX cannot commit merges yet. Please commit your changes from the command line."];
333                 return;
334         }
336         if ([[cachedFilesController arrangedObjects] count] == 0) {
337                 [[repository windowController] showMessageSheet:@"No changes to commit" infoText:@"You must first stage some changes before committing"];
338                 return;
339         }               
340         
341         NSString *commitMessage = [commitMessageView string];
342         if ([commitMessage length] < 3) {
343                 [[repository windowController] showMessageSheet:@"Commitmessage missing" infoText:@"Please enter a commit message before committing"];
344                 return;
345         }
347         [cachedFilesController setSelectionIndexes:[NSIndexSet indexSet]];
348         [unstagedFilesController setSelectionIndexes:[NSIndexSet indexSet]];
350         NSString *commitSubject;
351         NSRange newLine = [commitMessage rangeOfString:@"\n"];
352         if (newLine.location == NSNotFound)
353                 commitSubject = commitMessage;
354         else
355                 commitSubject = [commitMessage substringToIndex:newLine.location];
356         
357         commitSubject = [@"commit: " stringByAppendingString:commitSubject];
359         NSString *commitMessageFile;
360         commitMessageFile = [repository.fileURL.path
361                                                  stringByAppendingPathComponent:@"COMMIT_EDITMSG"];
363         [commitMessage writeToFile:commitMessageFile atomically:YES encoding:NSUTF8StringEncoding error:nil];
365         self.busy++;
366         self.status = @"Creating tree..";
367         NSString *tree = [repository outputForCommand:@"write-tree"];
368         if ([tree length] != 40)
369                 return [self commitFailedBecause:@"Could not create a tree"];
371         int ret;
373         NSMutableArray *arguments = [NSMutableArray arrayWithObjects:@"commit-tree", tree, nil];
374         NSString *parent = amend ? @"HEAD^" : @"HEAD";
375         if ([repository parseReference:parent]) {
376                 [arguments addObject:@"-p"];
377                 [arguments addObject:parent];
378         }
380         NSString *commit = [repository outputForArguments:arguments
381                                                                                   inputString:commitMessage
382                                                            byExtendingEnvironment:amendEnvironment
383                                                                                          retValue: &ret];
385         if (ret || [commit length] != 40)
386                 return [self commitFailedBecause:@"Could not create a commit object"];
388         if (![repository executeHook:@"pre-commit" output:nil])
389                 return [self commitFailedBecause:@"Pre-commit hook failed"];
391         if (![repository executeHook:@"commit-msg" withArgs:[NSArray arrayWithObject:commitMessageFile] output:nil])
392     return [self commitFailedBecause:@"Commit-msg hook failed"];
394         [repository outputForArguments:[NSArray arrayWithObjects:@"update-ref", @"-m", commitSubject, @"HEAD", commit, nil]
395                                                   retValue: &ret];
396         if (ret)
397                 return [self commitFailedBecause:@"Could not update HEAD"];
399         if (![repository executeHook:@"post-commit" output:nil])
400                 [webController setStateMessage:[NSString stringWithFormat:@"Post-commit hook failed, however, successfully created commit %@", commit]];
401         else
402                 [webController setStateMessage:[NSString stringWithFormat:@"Successfully created commit %@", commit]];
404         repository.hasChanged = YES;
405         self.busy--;
406         [commitMessageView setString:@""];
407         amend = NO;
408         amendEnvironment = nil;
409         [self refresh:self];
410         self.amend = NO;
413 - (void) stageHunk:(NSString *)hunk reverse:(BOOL)reverse
415         [self processHunk:hunk stage:TRUE reverse:reverse];
418 - (void)discardHunk:(NSString *)hunk
420         [self processHunk:hunk stage:FALSE reverse:TRUE];
423 - (void)processHunk:(NSString *)hunk stage:(BOOL)stage reverse:(BOOL)reverse
425         NSMutableArray *array = [NSMutableArray arrayWithObjects:@"apply", nil];
426         if (stage)
427                 [array addObject:@"--cached"];
428         if (reverse)
429                 [array addObject:@"--reverse"];
431         int ret = 1;
432         NSString *error = [repository outputForArguments:array
433                                                                                  inputString:hunk
434                                                                                         retValue:&ret];
436         // FIXME: show this error, rather than just logging it
437         if (ret)
438                 NSLog(@"Error: %@", error);
440         // TODO: We should do this smarter by checking if the file diff is empty, which is faster.
441         [self refresh:self]; 
444 @end