2 // PBGitCommitController.m
5 // Created by Pieter de Bie on 19-09-08.
6 // Copyright 2008 __MyCompanyName__. All rights reserved.
9 #import "PBGitCommitController.h"
10 #import "NSFileHandleExt.h"
11 #import "PBChangedFile.h"
12 #import "PBWebChangesController.h"
13 #import "NSString_RegEx.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;
24 @implementation PBGitCommitController
26 @synthesize files, status, busy, amend;
30 self.files = [NSMutableArray array];
34 [commitMessageView setTypingAttributes:[NSDictionary dictionaryWithObject:[NSFont fontWithName:@"Monaco" size:12.0] forKey:NSFontAttributeName]];
36 [unstagedFilesController setFilterPredicate:[NSPredicate predicateWithFormat:@"hasUnstagedChanges == 1"]];
37 [cachedFilesController setFilterPredicate:[NSPredicate predicateWithFormat:@"hasStagedChanges == 1"]];
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]]];
47 [webController closeView];
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];
71 - (void) setAmend:(BOOL)newAmend
73 if (newAmend == amend)
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:]
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];
86 amendEnvironment = [NSDictionary dictionaryWithObjectsAndKeys:[match objectAtIndex:1], @"GIT_AUTHOR_NAME",
87 [match objectAtIndex:2], @"GIT_AUTHOR_EMAIL",
88 [match objectAtIndex:3], @"GIT_AUTHOR_DATE",
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;
106 - (NSArray *) linesFromNotification:(NSNotification *)notification
108 NSDictionary *userInfo = [notification userInfo];
109 NSData *data = [userInfo valueForKey:NSFileHandleNotificationDataItem];
113 NSString* string = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
117 // Strip trailing newline
118 if ([string hasSuffix:@"\n"])
119 string = [string substringToIndex:[string length]-1];
121 NSArray *lines = [string componentsSeparatedByString:@"\0"];
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";
136 - (void) refresh:(id) sender
138 if (![repository workingDirectory])
141 self.status = @"Refreshing index…";
143 // If self.busy reaches 0, all tasks have finished
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];
158 [handle readToEndOfFileInBackgroundAndNotify];
161 handle = [repository handleInWorkDirForArguments:[NSArray arrayWithObjects:@"diff-files", @"-z", nil]];
162 [nc addObserver:self selector:@selector(readUnstagedFiles:) name:NSFileHandleReadToEndOfFileCompletionNotification object:handle];
164 [handle readToEndOfFileInBackgroundAndNotify];
167 handle = [repository handleInWorkDirForArguments:[NSArray arrayWithObjects:@"diff-index", @"--cached", @"-z", [self parentTree], nil]];
168 [nc addObserver:self selector:@selector(readCachedFiles:) name:NSFileHandleReadToEndOfFileCompletionNotification object:handle];
170 [handle readToEndOfFileInBackgroundAndNotify];
172 // Reload refs (in case HEAD changed)
173 [repository reloadRefs];
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 :)
190 NSMutableArray *deleteFiles = [NSMutableArray array];
191 for (PBChangedFile *file in files) {
192 if (!file.hasStagedChanges && !file.hasUnstagedChanges)
193 [deleteFiles addObject:file];
196 if ([deleteFiles count]) {
197 [self willChangeValueForKey:@"files"];
198 for (PBChangedFile *file in deleteFiles)
199 [files removeObject:file];
200 [self didChangeValueForKey:@"files"];
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)
214 [dictionary setObject:fileStatus forKey:path];
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
227 for (NSString *line in lines) {
230 fileStatus = [line componentsSeparatedByString:@" "];
235 [dictionary setObject:fileStatus forKey:line];
240 - (void) addFilesFromDictionary:(NSMutableDictionary *)dictionary staged:(BOOL)staged tracked:(BOOL)tracked
242 // Iterate over all existing files
243 for (PBChangedFile *file in files) {
244 NSArray *fileStatus = [dictionary objectForKey:file.path];
245 // Object found, this is still a cached / uncached thing
248 NSString *mode = [[fileStatus objectAtIndex:0] substringFromIndex:1];
249 NSString *sha = [fileStatus objectAtIndex:2];
250 file.commitBlobSHA = sha;
251 file.commitBlobMode = mode;
254 file.hasStagedChanges = YES;
256 file.hasUnstagedChanges = YES;
258 // Untracked file, set status to NEW, only unstaged changes
259 file.hasStagedChanges = NO;
260 file.hasUnstagedChanges = YES;
263 [dictionary removeObjectForKey:file.path];
264 } else { // Object not found, let's remove it from the changes
266 file.hasStagedChanges = NO;
267 else if (tracked && file.status != NEW) // Only remove it if it's not an untracked file. We handle that with the other thing
268 file.hasUnstagedChanges = NO;
269 else if (!tracked && file.status == NEW)
270 file.hasUnstagedChanges = NO;
275 if (![[dictionary allKeys] count])
278 [self willChangeValueForKey:@"files"];
279 for (NSString *path in [dictionary allKeys]) {
280 NSArray *fileStatus = [dictionary objectForKey:path];
282 PBChangedFile *file = [[PBChangedFile alloc] initWithPath:path];
283 if ([[fileStatus objectAtIndex:4] isEqualToString:@"D"])
284 file.status = DELETED;
285 else if([[fileStatus objectAtIndex:0] isEqualToString:@":000000"])
288 file.status = MODIFIED;
291 file.commitBlobMode = [[fileStatus objectAtIndex:0] substringFromIndex:1];
292 file.commitBlobSHA = [fileStatus objectAtIndex:2];
295 file.hasStagedChanges = staged;
296 file.hasUnstagedChanges = !staged;
298 [files addObject: file];
300 [self didChangeValueForKey:@"files"];
303 - (void) readUnstagedFiles:(NSNotification *)notification
305 NSArray *lines = [self linesFromNotification:notification];
306 NSMutableDictionary *dic = [self dictionaryForLines:lines];
307 [self addFilesFromDictionary:dic staged:NO tracked:YES];
308 [self doneProcessingIndex];
311 - (void) readCachedFiles:(NSNotification *)notification
313 NSArray *lines = [self linesFromNotification:notification];
314 NSMutableDictionary *dic = [self dictionaryForLines:lines];
315 [self addFilesFromDictionary:dic staged:YES tracked:YES];
316 [self doneProcessingIndex];
319 - (void) commitFailedBecause:(NSString *)reason
322 self.status = [@"Commit failed: " stringByAppendingString:reason];
323 [[repository windowController] showMessageSheet:@"Commit failed" infoText:reason];
327 - (IBAction) commit:(id) sender
329 if ([[NSFileManager defaultManager] fileExistsAtPath:[repository.fileURL.path stringByAppendingPathComponent:@"MERGE_HEAD"]]) {
330 [[repository windowController] showMessageSheet:@"Cannot commit merges" infoText:@"GitX cannot commit merges yet. Please commit your changes from the command line."];
334 if ([[cachedFilesController arrangedObjects] count] == 0) {
335 [[repository windowController] showMessageSheet:@"No changes to commit" infoText:@"You must first stage some changes before committing"];
339 NSString *commitMessage = [commitMessageView string];
340 if ([commitMessage length] < 3) {
341 [[repository windowController] showMessageSheet:@"Commitmessage missing" infoText:@"Please enter a commit message before committing"];
345 [cachedFilesController setSelectionIndexes:[NSIndexSet indexSet]];
346 [unstagedFilesController setSelectionIndexes:[NSIndexSet indexSet]];
348 NSString *commitSubject;
349 NSRange newLine = [commitMessage rangeOfString:@"\n"];
350 if (newLine.location == NSNotFound)
351 commitSubject = commitMessage;
353 commitSubject = [commitMessage substringToIndex:newLine.location];
355 commitSubject = [@"commit: " stringByAppendingString:commitSubject];
357 NSString *commitMessageFile;
358 commitMessageFile = [repository.fileURL.path
359 stringByAppendingPathComponent:@"COMMIT_EDITMSG"];
361 [commitMessage writeToFile:commitMessageFile atomically:YES encoding:NSUTF8StringEncoding error:nil];
364 self.status = @"Creating tree..";
365 NSString *tree = [repository outputForCommand:@"write-tree"];
366 if ([tree length] != 40)
367 return [self commitFailedBecause:@"Could not create a tree"];
371 NSMutableArray *arguments = [NSMutableArray arrayWithObjects:@"commit-tree", tree, nil];
372 NSString *parent = amend ? @"HEAD^" : @"HEAD";
373 if ([repository parseReference:parent]) {
374 [arguments addObject:@"-p"];
375 [arguments addObject:parent];
378 NSString *commit = [repository outputForArguments:arguments
379 inputString:commitMessage
380 byExtendingEnvironment:amendEnvironment
383 if (ret || [commit length] != 40)
384 return [self commitFailedBecause:@"Could not create a commit object"];
386 if (![repository executeHook:@"pre-commit" output:nil])
387 return [self commitFailedBecause:@"Pre-commit hook failed"];
389 if (![repository executeHook:@"commit-msg" withArgs:[NSArray arrayWithObject:commitMessageFile] output:nil])
390 return [self commitFailedBecause:@"Commit-msg hook failed"];
392 [repository outputForArguments:[NSArray arrayWithObjects:@"update-ref", @"-m", commitSubject, @"HEAD", commit, nil]
395 return [self commitFailedBecause:@"Could not update HEAD"];
397 if (![repository executeHook:@"post-commit" output:nil])
398 [webController setStateMessage:[NSString stringWithFormat:@"Post-commit hook failed, however, successfully created commit %@", commit]];
400 [webController setStateMessage:[NSString stringWithFormat:@"Successfully created commit %@", commit]];
402 repository.hasChanged = YES;
404 [commitMessageView setString:@""];
406 amendEnvironment = nil;
411 - (void) stageHunk:(NSString *)hunk reverse:(BOOL)reverse
413 [self processHunk:hunk stage:TRUE reverse:reverse];
416 - (void)discardHunk:(NSString *)hunk
418 [self processHunk:hunk stage:FALSE reverse:TRUE];
421 - (void)processHunk:(NSString *)hunk stage:(BOOL)stage reverse:(BOOL)reverse
423 NSMutableArray *array = [NSMutableArray arrayWithObjects:@"apply", nil];
425 [array addObject:@"--cached"];
427 [array addObject:@"--reverse"];
430 NSString *error = [repository outputForArguments:array
434 // FIXME: show this error, rather than just logging it
436 NSLog(@"Error: %@", error);
438 // TODO: We should do this smarter by checking if the file diff is empty, which is faster.