git-gui: check browser/blame arguments carefully

git gui offers two related commands, browser and blame, that provide graphical interfaces driven by git ls-tree and git blame. As such, the arguments to git-gui need to satisfy those two git commands. But, git-gui does not assure this leading to confusing or incorrect results. For instance 'git browser <non-existent path>' shows a blank browser window rather than error message. Also, commit 3e45ee1ef2 ("git-gui: Smarter command line parsing for browser, blame", 2007-05-08) implemented code to allow giving path before rev on the command line, and unconditionally uses the worktree to disambiguate. As a result, the following command run in a current git-gui checkout of the master branch shows the master branch version of blame.tcl, when none should be shown as that file does not exist in gitgui-0.6.0. git gui blame lib/blame.tcl gitgui-0.6.0 This 'file before rev' feature in git-gui mirrors ideas considered when git's user interface was very young, but no such feature is documented for any git command. Rather than try to fix an idea git itself rejected, let's just remove this broken and hopefully unused feature. git-gui browser|blame both accept 'rev' and 'path' as command line arguments. rev defaults to 'HEAD' if not given, while path must be given. path names a directory tree to ls-tree or a file to blame. path must exist in rev for ls-tree and for blame. In addition git blame will include uncommitted changes from the worktree file at 'path' if rev is not given (thus defaulting to HEAD), but still requires that the file exists in HEAD. So, let's clean up the parser to check that the arguments are usable. - give a full synopsis, including '--' that may be used to separate rev and path. (as path is the required final arg, -- gives no extra info) - explicitly check the number of arguments - use rev-parse to assure a user supplied rev is valid - use ls-tree to assure that path exists in rev - for blame only, with no rev given and a worktree existing, also assure that path points to a file in the worktree With these changes, error messages are thrown by the parser if the path or rev are not known: no blank or erroneous displays are created. Also, this avoids accessing the worktree except in the specific use case supported by blame / git-blame, meaning browser|blame now also work without a worktree. Signed-off-by: Mark Levedahl <mlevedahl@gmail.com> Signed-off-by: Johannes Sixt <j6t@kdbg.org>

Mark Levedahl committed May 31, 2026 at 19:02 UTC 3fbef193c7e49e8edb5a4421c465df67de944da6
1 file changed +63 -59
git-gui.sh
+63 -59
@@ -2999,101 +2999,105 @@ proc normalize_relpath {path} {
2999 }
3000 }
3001
3002 +proc show_parse_err {err} {
3003 + if {[tk windowingsystem] eq "win32"} {
3004 + catch {wm withdraw .}
3005 + error_popup $err
3006 + } else {
3007 + puts stderr $err
3008 + }
3009 + exit 1
3010 +}
3011 +
3012 # -- Not a normal commit type invocation? Do that instead!
3013 #
3014 switch -- $subcommand {
3015 browser -
3016 blame {
3017 if {$subcommand eq "blame"} {
3008 - set subcommand_args {[--line=<num>] rev? path}
3018 + set subcommand_args {[--line=<num>] [rev] [--] <filename>}
3019 + set required_pathtype blob
3020 } else {
3010 - set subcommand_args {rev? path}
3021 + set subcommand_args {[rev] [--] <dirname>}
3022 + set required_pathtype tree
3023 }
3012 - if {$argv eq {}} usage
3024 + set maxargs [llength $subcommand_args]
3025 + set nargs [llength $argv]
3026 + if {$nargs < 1 || $nargs > $maxargs} usage
3027 set head {}
3028 set path {}
3029 set jump_spec {}
3016 - set is_path 0
3017 - foreach a $argv {
3018 - set p [file join $_prefix $a]
3030
3020 - if {$is_path || [file exists $p]} {
3021 - if {$path ne {}} usage
3022 - set path [normalize_relpath $p]
3023 - break
3031 + set iarg 0
3032 + foreach a $argv {
3033 + incr iarg
3034 + if {$iarg == $nargs} {
3035 + # final argument is path
3036 + set path [normalize_relpath [file join $_prefix $a]]
3037 } elseif {$a eq {--}} {
3025 - if {$path ne {}} {
3026 - if {$head ne {}} usage
3027 - set head $path
3028 - set path {}
3038 + # allow before required final arg that must be path
3039 + if {$iarg != $nargs - 1} {
3040 + usage
3041 }
3030 - set is_path 1
3042 } elseif {[regexp {^--line=(\d+)$} $a a lnum]} {
3032 - if {$jump_spec ne {} || $head ne {}} usage
3043 + # --line can only be the first arg
3044 + if {$iarg != 1 || $subcommand ne {blame}} usage
3045 set jump_spec [list $lnum]
3046 } elseif {$head eq {}} {
3035 - if {$head ne {}} usage
3047 set head $a
3037 - set is_path 1
3048 } else {
3049 usage
3050 }
3051 }
3042 - unset is_path
3052
3044 - if {$head ne {} && $path eq {}} {
3045 - if {[string index $head 0] eq {/}} {
3046 - set path [normalize_relpath $head]
3047 - set head {}
3053 + # If head not given, use current branch (HEAD),
3054 + # and blame will use worktree if there is one.
3055 + set use_worktree 0
3056 + if {$head eq {}} {
3057 + load_current_branch
3058 + set head $current_branch
3059 + if {$subcommand eq {blame} && ![is_bare]} {
3060 + if {![file isfile $path]} {
3061 + show_parse_err [mc "fatal: no such file '%s' in worktree" $path]
3062 + }
3063 + set use_worktree 1
3064 + }
3065 + } else {
3066 + if {[catch {
3067 + set commitid \
3068 + [git rev-parse --verify --end-of-options \
3069 + [strcat $head "^{commit}"]]
3070 + }]} {
3071 + show_parse_err [mc "fatal: '%s' is not a valid rev'" $head]
3072 } else {
3049 - set path [normalize_relpath $_prefix$head]
3050 - set head {}
3073 + set current_branch $head
3074 }
3075 }
3076
3054 - if {$head eq {}} {
3055 - load_current_branch
3056 - } else {
3057 - if {[regexp [string map "@@ [expr $hashlength - 1]" {^[0-9a-f]{1,@@}$}] $head]} {
3058 - if {[catch {
3059 - set head [git rev-parse --verify $head]
3060 - } err]} {
3061 - if {[tk windowingsystem] eq "win32"} {
3062 - tk_messageBox -icon error -title [mc Error] -message $err
3063 - } else {
3064 - puts stderr $err
3065 - }
3066 - exit 1
3067 - }
3077 + # check path is known in head, and is file / directory as required
3078 + set pathtype {}
3079 + catch {set pathtype [git ls-tree {--format=%(objecttype)} $head $path]}
3080 + if {$pathtype ne {} && $path eq {.}} {
3081 + # ls-tree gives contents of root-dir, we need root-dir itself
3082 + set pathtype {tree}
3083 + }
3084 +
3085 + if {$pathtype ne $required_pathtype} {
3086 + switch -- $required_pathtype {
3087 + tree {show_parse_err \
3088 + [mc "'%s' is not a directory in rev '%s'" $path $head]}
3089 + blob {show_parse_err \
3090 + [mc "'%s' is not a filename in rev '%s'" $path $head]}
3091 }
3069 - set current_branch $head
3092 }
3093
3094 wm deiconify .
3095 switch -- $subcommand {
3096 browser {
3075 - if {$jump_spec ne {}} usage
3076 - if {$head eq {}} {
3077 - if {$path ne {} && [file isdirectory $path]} {
3078 - set head $current_branch
3079 - } else {
3080 - set head $path
3081 - set path {}
3082 - }
3083 - }
3097 browser::new $head $path
3098 }
3099 blame {
3087 - if {$head eq {} && ![file exists $path]} {
3088 - catch {wm withdraw .}
3089 - tk_messageBox \
3090 - -icon error \
3091 - -type ok \
3092 - -title [mc "git-gui: fatal error"] \
3093 - -message [mc "fatal: cannot stat path %s: No such file or directory" $path]
3094 - exit 1
3095 - }
3096 - blame::new $head $path $jump_spec
3100 + blame::new [expr {$use_worktree ? {} : $head}] $path $jump_spec
3101 }
3102 }
3103 return