From 8a6809884863eb60c9c0f8727ff77722d6b85414 Mon Sep 17 00:00:00 2001 From: Jonas Fonseca Date: Sat, 22 Mar 2008 03:05:00 +0100 Subject: [PATCH] Add blame view It may both be entered from the command line using: tig blame [rev] path or from either the status and stage, as well as by using the tree view to navigte. --- manual.txt | 4 + tig.1.txt | 7 +- tig.c | 539 +++++++++++++++++++++++++++++++++++++++++++++++++++- tigrc.5.txt | 8 + 4 files changed, 553 insertions(+), 5 deletions(-) diff --git a/manual.txt b/manual.txt index fd466db..4e8b402 100644 --- a/manual.txt +++ b/manual.txt @@ -210,6 +210,9 @@ The blob view:: Displays the file content or "blob" of data associated with a file name. +The blame view:: + Displays the file content annotated or blamed by commits. + The status view:: Displays status of files in the working tree and allows changes to be staged/unstaged as well as adding of untracked files. @@ -262,6 +265,7 @@ l Switch to log view. p Switch to pager view. t Switch to (directory) tree view. f Switch to (file) blob view. +B Switch to blame view. h Switch to help view S Switch to status view c Switch to stage view diff --git a/tig.1.txt b/tig.1.txt index 8730c06..b3f8905 100644 --- a/tig.1.txt +++ b/tig.1.txt @@ -10,6 +10,7 @@ SYNOPSIS [verse] tig [options] [revisions] [--] [paths] tig show [options] [revisions] [--] [paths] +tig blame [rev] path tig status tig < [git command output] @@ -38,6 +39,10 @@ command. show:: Open diff view using the given git show options. +blame:: + Show given file annotated or blamed by commits. + Optionally limited from given revision. + status:: Start up in status view. @@ -148,7 +153,7 @@ include::BUGS[] COPYRIGHT --------- -Copyright (c) 2006-2007 Jonas Fonseca +Copyright (c) 2006-2008 Jonas Fonseca This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by diff --git a/tig.c b/tig.c index 2551ba3..8db304e 100644 --- a/tig.c +++ b/tig.c @@ -1,4 +1,4 @@ -/* Copyright (c) 2006-2007 Jonas Fonseca +/* Copyright (c) 2006-2008 Jonas Fonseca * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as @@ -94,9 +94,10 @@ static size_t utf8_length(const char *string, size_t max_width, int *trimmed, bo #define DATE_COLS STRING_SIZE("2006-04-29 14:21 ") #define AUTHOR_COLS 20 +#define ID_COLS 8 /* The default interval between line numbers. */ -#define NUMBER_INTERVAL 1 +#define NUMBER_INTERVAL 5 #define TABSIZE 8 @@ -129,6 +130,7 @@ static size_t utf8_length(const char *string, size_t max_width, int *trimmed, bo #define TIG_PAGER_CMD "" #define TIG_STATUS_CMD "" #define TIG_STAGE_CMD "" +#define TIG_BLAME_CMD "" /* Some ascii-shorthands fitted into the ncurses namespace. */ #define KEY_TAB '\t' @@ -314,6 +316,7 @@ sq_quote(char buf[SIZEOF_STR], size_t bufsize, const char *src) REQ_(VIEW_LOG, "Show log view"), \ REQ_(VIEW_TREE, "Show tree view"), \ REQ_(VIEW_BLOB, "Show blob view"), \ + REQ_(VIEW_BLAME, "Show blame view"), \ REQ_(VIEW_HELP, "Show help page"), \ REQ_(VIEW_PAGER, "Show pager view"), \ REQ_(VIEW_STATUS, "Show status view"), \ @@ -418,12 +421,13 @@ static const char usage[] = "\n" "Usage: tig [options] [revs] [--] [paths]\n" " or: tig show [options] [revs] [--] [paths]\n" +" or: tig blame [rev] path\n" " or: tig status\n" " or: tig < [git command output]\n" "\n" "Options:\n" " -v, --version Show version and exit\n" -" -h, --help Show help message and exit\n"; +" -h, --help Show help message and exit"; /* Option and state variables. */ static bool opt_date = TRUE; @@ -436,6 +440,7 @@ static int opt_tab_size = TABSIZE; static enum request opt_request = REQ_VIEW_MAIN; static char opt_cmd[SIZEOF_STR] = ""; static char opt_path[SIZEOF_STR] = ""; +static char opt_ref[SIZEOF_REF] = ""; static FILE *opt_pipe = NULL; static char opt_encoding[20] = "UTF-8"; static bool opt_utf8 = TRUE; @@ -509,6 +514,20 @@ parse_options(int argc, char *argv[]) warn("ignoring arguments after `%s'", subcommand); return TRUE; + } else if (!strcmp(subcommand, "blame")) { + opt_request = REQ_VIEW_BLAME; + if (argc <= 2 || argc > 4) + die("invalid number of options to blame\n\n%s", usage); + + i = 2; + if (argc == 4) { + string_ncopy(opt_ref, argv[i], strlen(argv[i])); + i++; + } + + string_ncopy(opt_path, argv[i], strlen(argv[i])); + return TRUE; + } else if (!strcmp(subcommand, "show")) { opt_request = REQ_VIEW_DIFF; @@ -655,7 +674,12 @@ LINE(STAT_SECTION, "", COLOR_CYAN, COLOR_DEFAULT, 0), \ LINE(STAT_NONE, "", COLOR_DEFAULT, COLOR_DEFAULT, 0), \ LINE(STAT_STAGED, "", COLOR_MAGENTA, COLOR_DEFAULT, 0), \ LINE(STAT_UNSTAGED,"", COLOR_MAGENTA, COLOR_DEFAULT, 0), \ -LINE(STAT_UNTRACKED,"", COLOR_MAGENTA, COLOR_DEFAULT, 0) +LINE(STAT_UNTRACKED,"", COLOR_MAGENTA, COLOR_DEFAULT, 0), \ +LINE(BLAME_DATE, "", COLOR_BLUE, COLOR_DEFAULT, 0), \ +LINE(BLAME_AUTHOR, "", COLOR_GREEN, COLOR_DEFAULT, 0), \ +LINE(BLAME_COMMIT, "", COLOR_DEFAULT, COLOR_DEFAULT, 0), \ +LINE(BLAME_ID, "", COLOR_MAGENTA, COLOR_DEFAULT, 0), \ +LINE(BLAME_LINENO, "", COLOR_CYAN, COLOR_DEFAULT, 0) enum line_type { #define LINE(type, line, fg, bg, attr) \ @@ -742,6 +766,7 @@ struct line { /* State flags */ unsigned int selected:1; + unsigned int dirty:1; void *data; /* User data */ }; @@ -764,6 +789,7 @@ static struct keybinding default_keybindings[] = { { 'l', REQ_VIEW_LOG }, { 't', REQ_VIEW_TREE }, { 'f', REQ_VIEW_BLOB }, + { 'B', REQ_VIEW_BLAME }, { 'p', REQ_VIEW_PAGER }, { 'h', REQ_VIEW_HELP }, { 'S', REQ_VIEW_STATUS }, @@ -827,6 +853,7 @@ static struct keybinding default_keybindings[] = { KEYMAP_(LOG), \ KEYMAP_(TREE), \ KEYMAP_(BLOB), \ + KEYMAP_(BLAME), \ KEYMAP_(PAGER), \ KEYMAP_(HELP), \ KEYMAP_(STATUS), \ @@ -1451,6 +1478,7 @@ static struct view_ops pager_ops; static struct view_ops main_ops; static struct view_ops tree_ops; static struct view_ops blob_ops; +static struct view_ops blame_ops; static struct view_ops help_ops; static struct view_ops status_ops; static struct view_ops stage_ops; @@ -1468,6 +1496,7 @@ static struct view views[] = { VIEW_(LOG, "log", &pager_ops, ref_head), VIEW_(TREE, "tree", &tree_ops, ref_commit), VIEW_(BLOB, "blob", &blob_ops, ref_blob), + VIEW_(BLAME, "blame", &blame_ops, ref_commit), VIEW_(HELP, "help", &help_ops, ""), VIEW_(PAGER, "pager", &pager_ops, "stdin"), VIEW_(STATUS, "status", &status_ops, ""), @@ -1546,6 +1575,32 @@ draw_view_line(struct view *view, unsigned int lineno) return draw_ok; } +static void +redraw_view_dirty(struct view *view) +{ + bool dirty = FALSE; + int lineno; + + for (lineno = 0; lineno < view->height; lineno++) { + struct line *line = &view->line[view->offset + lineno]; + + if (!line->dirty) + continue; + line->dirty = 0; + dirty = TRUE; + if (!draw_view_line(view, lineno)) + break; + } + + if (!dirty) + return; + redrawwin(view->win); + if (input_mode) + wnoutrefresh(view->win); + else + wrefresh(view->win); +} + static void redraw_view_from(struct view *view, int lineno) { @@ -2197,6 +2252,7 @@ update_view(struct view *view) * might have rearranged things. */ redraw_view(view); + } else if (redraw_from >= 0) { /* If this is an incremental update, redraw the previous line * since for commits some members could have changed when @@ -2214,6 +2270,9 @@ update_view(struct view *view) redraw_view_from(view, redraw_from); } + if (view == VIEW(REQ_VIEW_BLAME)) + redraw_view_dirty(view); + /* Update the title _after_ the redraw so that if the redraw picks up a * commit reference in view->ref it'll be available here. */ update_view_title(view); @@ -2490,6 +2549,20 @@ view_driver(struct view *view, enum request request) scroll_view(view, request); break; + case REQ_VIEW_BLAME: + if (!opt_path[0]) { + report("No file chosen, press %s to open tree view", + get_key(REQ_VIEW_TREE)); + break; + } + if (opt_path[strlen(opt_path) - 1] == '/') { + report("Cannot show blame for directory %s", opt_path); + break; + } + string_copy(opt_ref, ref_commit); + open_view(view, request, OPEN_DEFAULT); + break; + case REQ_VIEW_BLOB: if (!ref_blob[0]) { report("No file chosen, press %s to open tree view", @@ -2539,6 +2612,8 @@ view_driver(struct view *view, enum request request) if ((view == VIEW(REQ_VIEW_DIFF) && view->parent == VIEW(REQ_VIEW_MAIN)) || + (view == VIEW(REQ_VIEW_DIFF) && + view->parent == VIEW(REQ_VIEW_BLAME)) || (view == VIEW(REQ_VIEW_STAGE) && view->parent == VIEW(REQ_VIEW_STATUS)) || (view == VIEW(REQ_VIEW_BLOB) && @@ -3248,6 +3323,448 @@ static struct view_ops blob_ops = { pager_select, }; +/* + * Blame backend + * + * Loading the blame view is a two phase job: + * + * 1. File content is read either using opt_path from the + * filesystem or using git-cat-file. + * 2. Then blame information is incrementally added by + * reading output from git-blame. + */ + +struct blame_commit { + char id[SIZEOF_REV]; /* SHA1 ID. */ + char title[128]; /* First line of the commit message. */ + char author[75]; /* Author of the commit. */ + struct tm time; /* Date from the author ident. */ + char filename[128]; /* Name of file. */ +}; + +struct blame { + struct blame_commit *commit; + unsigned int header:1; + char text[1]; +}; + +#define BLAME_CAT_FILE_CMD "git cat-file blob %s:%s" +#define BLAME_INCREMENTAL_CMD "git blame --incremental %s %s" + +static bool +blame_open(struct view *view) +{ + char path[SIZEOF_STR]; + char ref[SIZEOF_STR] = ""; + + if (sq_quote(path, 0, opt_path) >= sizeof(path)) + return FALSE; + + if (*opt_ref && sq_quote(ref, 0, opt_ref) >= sizeof(ref)) + return FALSE; + + if (*opt_ref) { + if (!string_format(view->cmd, BLAME_CAT_FILE_CMD, ref, path)) + return FALSE; + } else { + view->pipe = fopen(opt_path, "r"); + if (!view->pipe && + !string_format(view->cmd, BLAME_CAT_FILE_CMD, "HEAD", path)) + return FALSE; + } + + if (!view->pipe) + view->pipe = popen(view->cmd, "r"); + if (!view->pipe) + return FALSE; + + if (!string_format(view->cmd, BLAME_INCREMENTAL_CMD, ref, path)) + return FALSE; + + string_format(view->ref, "%s ...", opt_path); + string_copy_rev(view->vid, opt_path); + set_nonblocking_input(TRUE); + + if (view->line) { + int i; + + for (i = 0; i < view->lines; i++) + free(view->line[i].data); + free(view->line); + } + + view->lines = view->line_alloc = view->line_size = view->lineno = 0; + view->offset = view->lines = view->lineno = 0; + view->line = NULL; + view->start_time = time(NULL); +} + +static struct blame_commit * +get_blame_commit(struct view *view, const char *id) +{ + size_t i; + + for (i = 0; i < view->lines; i++) { + struct blame *blame = view->line[i].data; + + if (!blame->commit) + continue; + + if (!strncmp(blame->commit->id, id, SIZEOF_REV - 1)) + return blame->commit; + } + + { + struct blame_commit *commit = calloc(1, sizeof(*commit)); + + if (commit) + string_ncopy(commit->id, id, SIZEOF_REV); + return commit; + } +} + +static bool +parse_number(char **posref, size_t *number, size_t min, size_t max) +{ + char *pos = *posref; + + *posref = NULL; + pos = strchr(pos + 1, ' '); + if (!pos || !isdigit(pos[1])) + return FALSE; + *number = atoi(pos + 1); + if (*number < min || *number > max) + return FALSE; + + *posref = pos; + return TRUE; +} + +static struct blame_commit * +parse_blame_commit(struct view *view, char *text, int *blamed) +{ + struct blame_commit *commit; + struct blame *blame; + char *pos = text + SIZEOF_REV - 1; + size_t lineno; + size_t group; + struct line *line; + + if (strlen(text) <= SIZEOF_REV || *pos != ' ') + return NULL; + + if (!parse_number(&pos, &lineno, 1, view->lines) || + !parse_number(&pos, &group, 1, view->lines - lineno + 1)) + return NULL; + + commit = get_blame_commit(view, text); + if (!commit) + return NULL; + + *blamed += group; + while (group--) { + struct line *line = &view->line[lineno + group - 1]; + + blame = line->data; + blame->commit = commit; + line->dirty = 1; + } + blame->header = 1; + + return commit; +} + +static bool +blame_read_file(struct view *view, char *line) +{ + if (!line) { + FILE *pipe = NULL; + + if (view->lines > 0) + pipe = popen(view->cmd, "r"); + view->cmd[0] = 0; + if (!pipe) { + report("Failed to load blame data"); + return TRUE; + } + + fclose(view->pipe); + view->pipe = pipe; + return FALSE; + + } else { + size_t linelen = strlen(line); + struct blame *blame = malloc(sizeof(*blame) + linelen); + + if (!line) + return FALSE; + + blame->commit = NULL; + strncpy(blame->text, line, linelen); + blame->text[linelen] = 0; + return add_line_data(view, blame, LINE_BLAME_COMMIT) != NULL; + } +} + +static bool +match_blame_header(const char *name, char **line) +{ + size_t namelen = strlen(name); + bool matched = !strncmp(name, *line, namelen); + + if (matched) + *line += namelen; + + return matched; +} + +static bool +blame_read(struct view *view, char *line) +{ + static struct blame_commit *commit = NULL; + static int blamed = 0; + static time_t author_time; + + if (*view->cmd) + return blame_read_file(view, line); + + if (!line) { + /* Reset all! */ + commit = NULL; + blamed = 0; + string_format(view->ref, "%s", view->vid); + if (view_is_displayed(view)) { + update_view_title(view); + redraw_view_from(view, 0); + } + return TRUE; + } + + if (!commit) { + commit = parse_blame_commit(view, line, &blamed); + string_format(view->ref, "%s %2d%%", view->vid, + blamed * 100 / view->lines); + + } else if (match_blame_header("author ", &line)) { + string_ncopy(commit->author, line, strlen(line)); + + } else if (match_blame_header("author-time ", &line)) { + author_time = (time_t) atol(line); + + } else if (match_blame_header("author-tz ", &line)) { + long tz; + + tz = ('0' - line[1]) * 60 * 60 * 10; + tz += ('0' - line[2]) * 60 * 60; + tz += ('0' - line[3]) * 60; + tz += ('0' - line[4]) * 60; + + if (line[0] == '-') + tz = -tz; + + author_time -= tz; + gmtime_r(&author_time, &commit->time); + + } else if (match_blame_header("summary ", &line)) { + string_ncopy(commit->title, line, strlen(line)); + + } else if (match_blame_header("filename ", &line)) { + string_ncopy(commit->filename, line, strlen(line)); + commit = NULL; + } + + return TRUE; +} + +static bool +blame_draw(struct view *view, struct line *line, unsigned int lineno, bool selected) +{ + int tilde_attr = -1; + struct blame *blame = line->data; + int col = 0; + + wmove(view->win, lineno, 0); + + if (selected) { + wattrset(view->win, get_line_attr(LINE_CURSOR)); + wchgat(view->win, -1, 0, LINE_CURSOR, NULL); + } else { + wattrset(view->win, A_NORMAL); + tilde_attr = get_line_attr(LINE_MAIN_DELIM); + } + + if (opt_date) { + int n; + + if (!selected) + wattrset(view->win, get_line_attr(LINE_MAIN_DATE)); + if (blame->commit) { + char buf[DATE_COLS + 1]; + int timelen; + + timelen = strftime(buf, sizeof(buf), DATE_FORMAT, &blame->commit->time); + n = draw_text(view, buf, view->width - col, FALSE, tilde_attr); + draw_text(view, " ", view->width - col - n, FALSE, tilde_attr); + } + + col += DATE_COLS; + wmove(view->win, lineno, col); + if (col >= view->width) + return TRUE; + } + + if (opt_author) { + int max = MIN(AUTHOR_COLS - 1, view->width - col); + + if (!selected) + wattrset(view->win, get_line_attr(LINE_MAIN_AUTHOR)); + if (blame->commit) + draw_text(view, blame->commit->author, max, TRUE, tilde_attr); + col += AUTHOR_COLS; + if (col >= view->width) + return TRUE; + wmove(view->win, lineno, col); + } + + { + int max = MIN(ID_COLS - 1, view->width - col); + + if (!selected) + wattrset(view->win, get_line_attr(LINE_BLAME_ID)); + if (blame->commit) + draw_text(view, blame->commit->id, max, FALSE, -1); + col += ID_COLS; + if (col >= view->width) + return TRUE; + wmove(view->win, lineno, col); + } + + { + unsigned long real_lineno = view->offset + lineno + 1; + char number[10] = " "; + int max = MIN(view->digits, STRING_SIZE(number)); + bool showtrimmed = FALSE; + + if (real_lineno == 1 || + (real_lineno % opt_num_interval) == 0) { + char fmt[] = "%1ld"; + + if (view->digits <= 9) + fmt[1] = '0' + view->digits; + + if (!string_format(number, fmt, real_lineno)) + number[0] = 0; + showtrimmed = TRUE; + } + + if (max > view->width - col) + max = view->width - col; + if (!selected) + wattrset(view->win, get_line_attr(LINE_BLAME_LINENO)); + col += draw_text(view, number, max, showtrimmed, tilde_attr); + if (col >= view->width) + return TRUE; + } + + if (!selected) + wattrset(view->win, A_NORMAL); + + if (col >= view->width) + return TRUE; + waddch(view->win, ACS_VLINE); + col++; + if (col >= view->width) + return TRUE; + waddch(view->win, ' '); + col++; + col += draw_text(view, blame->text, view->width - col, TRUE, tilde_attr); + + return TRUE; +} + +static enum request +blame_request(struct view *view, enum request request, struct line *line) +{ + enum open_flags flags = display[0] == view ? OPEN_SPLIT : OPEN_DEFAULT; + struct blame *blame = line->data; + + switch (request) { + case REQ_ENTER: + if (!blame->commit) { + report("No commit loaded yet"); + break; + } + + if (!strcmp(blame->commit->id, "0000000000000000000000000000000000000000")) { + char path[SIZEOF_STR]; + + if (sq_quote(path, 0, view->vid) >= sizeof(path)) + break; + string_format(opt_cmd, "git diff-index --root --patch-with-stat -C -M --cached HEAD -- %s 2>/dev/null", path); + } + + open_view(view, REQ_VIEW_DIFF, flags); + break; + + default: + return request; + } + + return REQ_NONE; +} + +static bool +blame_grep(struct view *view, struct line *line) +{ + struct blame *blame = line->data; + struct blame_commit *commit = blame->commit; + regmatch_t pmatch; + +#define MATCH(text) \ + (*text && regexec(view->regex, text, 1, &pmatch, 0) != REG_NOMATCH) + + if (commit) { + char buf[DATE_COLS + 1]; + + if (MATCH(commit->title) || + MATCH(commit->author) || + MATCH(commit->id)) + return TRUE; + + if (strftime(buf, sizeof(buf), DATE_FORMAT, &commit->time) && + MATCH(buf)) + return TRUE; + } + + return MATCH(blame->text); + +#undef MATCH +} + +static void +blame_select(struct view *view, struct line *line) +{ + struct blame *blame = line->data; + struct blame_commit *commit = blame->commit; + + if (!commit) + return; + + if (!strcmp(commit->id, "0000000000000000000000000000000000000000")) + string_ncopy(ref_commit, "HEAD", 4); + else + string_copy_rev(ref_commit, commit->id); +} + +static struct view_ops blame_ops = { + "line", + blame_open, + blame_read, + blame_draw, + blame_request, + blame_grep, + blame_select, +}; /* * Status backend @@ -3725,6 +4242,13 @@ status_request(struct view *view, enum request request, struct line *line) open_editor(status->status != '?', status->new.name); break; + case REQ_VIEW_BLAME: + if (status) { + string_copy(opt_path, status->new.name); + opt_ref[0] = 0; + } + return request; + case REQ_ENTER: /* After returning the status view has been split to * show the stage view. No further reloading is @@ -3977,6 +4501,13 @@ stage_request(struct view *view, enum request request, struct line *line) open_editor(stage_status.status != '?', stage_status.new.name); break; + case REQ_VIEW_BLAME: + if (stage_status.new.name[0]) { + string_copy(opt_path, stage_status.new.name); + opt_ref[0] = 0; + } + return request; + case REQ_ENTER: pager_request(view, request, line); break; diff --git a/tigrc.5.txt b/tigrc.5.txt index bed3116..86d94c0 100644 --- a/tigrc.5.txt +++ b/tigrc.5.txt @@ -179,6 +179,7 @@ view-diff Show diff view view-log Show log view view-tree Show tree view view-blob Show blob view +view-blame Show blame view view-status Show status view view-stage Show stage view view-pager Show pager view @@ -349,6 +350,13 @@ delimiting long author names and labels for tag and branch references. *main-date*, *main-author*, *main-commit*, *main-delim*, *main-tag*, *main-local-tag*, *main-ref*, *main-remote*, *main-revgraph* +Blame view colors:: + +The colors used for the blame view are similar to those in the main view. +The commit ID color can be colored using *blame-id*. + +*blame-date*, *blame-author*, *blame-commit*, *blame-id*, *blame-lineno* + -- Highlighting -- 2.32.0.93.g670b81a890