Skip to content

Commit 6fb141d

Browse files
yarikopticclaude
andcommitted
Add -D/--decorations option with github-markdown output to show-paths
Add a new decoration mode that produces GitHub-flavored markdown with clickable blob links to specific line numbers. Match lines are bolded with **...** for visual distinction. Filenames are hyperlinked to the file's blob URL. Supports both inline and full-lines formats. Other decoration modes: 'color' (force ANSI), 'none' (plain text), 'auto' (defers to --color for backward compatibility). Co-Authored-By: Claude Code 2.1.63 / Claude Opus 4.6 <noreply@anthropic.com>
1 parent aace6f2 commit 6fb141d

2 files changed

Lines changed: 450 additions & 23 deletions

File tree

bin/show-paths

Lines changed: 201 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,9 @@ Examples from my pains of the past two days
8484
""" # noqa: E501
8585

8686
import argparse
87+
import os
8788
import re
89+
import subprocess
8890
import sys
8991

9092
try:
@@ -111,6 +113,80 @@ class ColoredHelpFormatter(argparse.RawDescriptionHelpFormatter):
111113
return help_text
112114

113115

116+
def get_github_context():
117+
"""Return (repo_toplevel, commit_sha, github_blob_base) or None.
118+
119+
Resolves git repository info and GitHub remote URL to construct
120+
blob URLs like https://github.com/owner/repo/blob/sha.
121+
"""
122+
try:
123+
toplevel = (
124+
subprocess.check_output(
125+
["git", "rev-parse", "--show-toplevel"], stderr=subprocess.DEVNULL
126+
)
127+
.decode()
128+
.strip()
129+
)
130+
sha = (
131+
subprocess.check_output(
132+
["git", "rev-parse", "HEAD"], stderr=subprocess.DEVNULL
133+
)
134+
.decode()
135+
.strip()
136+
)
137+
except (subprocess.CalledProcessError, FileNotFoundError):
138+
return None
139+
140+
# Determine remote name: tracking remote for current branch, fallback to origin
141+
remote_name = "origin"
142+
try:
143+
ref = (
144+
subprocess.check_output(
145+
["git", "symbolic-ref", "-q", "HEAD"], stderr=subprocess.DEVNULL
146+
)
147+
.decode()
148+
.strip()
149+
)
150+
branch = ref.replace("refs/heads/", "")
151+
remote_name = (
152+
subprocess.check_output(
153+
["git", "config", f"branch.{branch}.remote"], stderr=subprocess.DEVNULL
154+
)
155+
.decode()
156+
.strip()
157+
)
158+
except (subprocess.CalledProcessError, FileNotFoundError):
159+
pass
160+
161+
try:
162+
remote_url = (
163+
subprocess.check_output(
164+
["git", "config", "--get", f"remote.{remote_name}.url"],
165+
stderr=subprocess.DEVNULL,
166+
)
167+
.decode()
168+
.strip()
169+
)
170+
except (subprocess.CalledProcessError, FileNotFoundError):
171+
return None
172+
173+
# Parse SSH or HTTPS GitHub URLs
174+
# SSH: git@github.com:owner/repo.git
175+
m = re.match(r"git@github\.com[:/](.+?)(?:\.git)?$", remote_url)
176+
if not m:
177+
# HTTPS: https://github.com/owner/repo.git
178+
m = re.match(r"https?://github\.com/(.+?)(?:\.git)?$", remote_url)
179+
if not m:
180+
# gh: shorthand
181+
m = re.match(r"gh:(.+?)(?:\.git)?$", remote_url)
182+
if not m:
183+
return None
184+
185+
owner_repo = m.group(1)
186+
blob_base = f"https://github.com/{owner_repo}/blob/{sha}"
187+
return toplevel, sha, blob_base
188+
189+
114190
def get_paths(lines):
115191
"""Generate indentation-based paths for given lines."""
116192
paths = []
@@ -162,6 +238,51 @@ def print_full_lines(paths, lines, use_color, prefix=""):
162238
printed.add(line_num)
163239

164240

241+
def print_inline_markdown(paths, lines, blob_url, use_color, prefix=""):
242+
"""Print paths and matched lines in inline format with GitHub markdown links."""
243+
for line_num, path in paths:
244+
path_str = ".".join([p[0] for p in path[:-1]])
245+
line = lines[line_num].rstrip()
246+
# GitHub uses 1-indexed line numbers
247+
gh_line = line_num + 1
248+
path_part = f" `{path_str}`" if path_str else ""
249+
if blob_url:
250+
text = f"**[`{line_num}:`]({blob_url}#L{gh_line}){path_part} `{line}`**"
251+
else:
252+
text = f"**`{line_num}:`{path_part} `{line}`**"
253+
if use_color:
254+
text = colored(text, "red")
255+
print(f"{prefix}{text}")
256+
257+
258+
def print_full_lines_markdown(paths, lines, blob_url, use_color, prefix=""):
259+
"""Print paths and matched lines in full-lines format with GitHub markdown links."""
260+
printed = set()
261+
for line_num, path in paths:
262+
for key, indent, i in path[:-1]:
263+
if i not in printed:
264+
gh_i = i + 1
265+
context_line = lines[i]
266+
if blob_url:
267+
text = f"[`{i} `]({blob_url}#L{gh_i}) `{context_line}`"
268+
else:
269+
text = f"`{i} ` `{context_line}`"
270+
if use_color:
271+
text = colored(text, attrs=["dark"])
272+
print(f"{prefix}{text}")
273+
printed.add(i)
274+
gh_line = line_num + 1
275+
match_line = lines[line_num]
276+
if blob_url:
277+
text = f"**[`{line_num}:`]({blob_url}#L{gh_line}) `{match_line}`**"
278+
else:
279+
text = f"**`{line_num}:` `{match_line}`**"
280+
if use_color:
281+
text = colored(text, "red")
282+
print(f"{prefix}{text}")
283+
printed.add(line_num)
284+
285+
165286
def filter_paths(lines, regex=None, line_numbers=None):
166287
"""Get and filter indentation-based paths for given lines."""
167288
paths = get_paths(lines)
@@ -175,9 +296,7 @@ def filter_paths(lines, regex=None, line_numbers=None):
175296

176297
if line_numbers:
177298
nums = set(line_numbers)
178-
paths = [
179-
(line_num, path) for line_num, path in paths if line_num in nums
180-
]
299+
paths = [(line_num, path) for line_num, path in paths if line_num in nums]
181300

182301
return paths
183302

@@ -221,16 +340,50 @@ def main():
221340
help="How to annotate output with filename: 'auto' (prefix when multiple files),"
222341
" 'prefix' (prepend filename: to each line), 'name' (print filename header)",
223342
)
343+
parser.add_argument(
344+
"-D",
345+
"--decorations",
346+
choices=["auto", "color", "none", "github-markdown"],
347+
default="auto",
348+
help="Decoration style: 'auto' (defers to --color), 'color' (force ANSI),"
349+
" 'none' (plain text), 'github-markdown' (markdown with GitHub blob links)",
350+
)
224351

225352
args = parser.parse_args()
226353

354+
decorations = args.decorations
355+
227356
# Determine if color output should be used
228-
use_color = args.color == "on" or (
229-
args.color == "auto" and sys.stdout.isatty() and colored
230-
)
357+
if decorations == "auto":
358+
# Defer to --color logic (backward compatible)
359+
use_color = args.color == "on" or (
360+
args.color == "auto" and sys.stdout.isatty() and colored
361+
)
362+
elif decorations == "color":
363+
use_color = True
364+
elif decorations == "none":
365+
use_color = False
366+
elif decorations == "github-markdown":
367+
# Colors on top of markdown when TTY
368+
use_color = sys.stdout.isatty() and colored is not None
369+
else:
370+
use_color = False
371+
231372
if use_color and not colored:
232373
raise RuntimeError("Need 'termcolor' package to get colors")
233374

375+
# Resolve GitHub context if needed
376+
github_context = None
377+
if decorations == "github-markdown":
378+
github_context = get_github_context()
379+
if github_context is None:
380+
print(
381+
"Error: --decorations=github-markdown requires a git repo"
382+
" with a GitHub remote",
383+
file=sys.stderr,
384+
)
385+
sys.exit(1)
386+
234387
# Compile regex if provided
235388
regex = re.compile(args.regex) if args.regex else None
236389

@@ -257,17 +410,49 @@ def main():
257410
if not paths:
258411
continue
259412

260-
prefix = f"{fname}:" if filename_mode == "prefix" else ""
261-
262-
if filename_mode == "name":
263-
print(fname)
264-
265-
if args.format == "inline":
266-
print_inline(paths, lines, use_color, prefix=prefix)
267-
elif args.format == "full-lines":
268-
print_full_lines(paths, lines, use_color, prefix=prefix)
413+
if decorations == "github-markdown":
414+
# Compute blob URL for this file
415+
if filepath is not None and github_context is not None:
416+
toplevel, _sha, blob_base = github_context
417+
rel_path = os.path.relpath(os.path.abspath(filepath), toplevel)
418+
blob_url = f"{blob_base}/{rel_path}"
419+
else:
420+
blob_url = None
421+
422+
# Build markdown prefix/header with hyperlinked filename
423+
if filename_mode == "prefix":
424+
if blob_url:
425+
prefix = f"[`{fname}`]({blob_url}):"
426+
else:
427+
prefix = f"`{fname}`:"
428+
else:
429+
prefix = ""
430+
431+
if filename_mode == "name":
432+
if blob_url:
433+
print(f"[`{fname}`]({blob_url})")
434+
else:
435+
print(f"`{fname}`")
436+
437+
if args.format == "inline":
438+
print_inline_markdown(paths, lines, blob_url, use_color, prefix=prefix)
439+
elif args.format == "full-lines":
440+
print_full_lines_markdown(
441+
paths, lines, blob_url, use_color, prefix=prefix
442+
)
443+
else:
444+
raise ValueError(args.format)
269445
else:
270-
raise ValueError(args.format)
446+
prefix = f"{fname}:" if filename_mode == "prefix" else ""
447+
448+
if filename_mode == "name":
449+
print(fname)
450+
if args.format == "inline":
451+
print_inline(paths, lines, use_color, prefix=prefix)
452+
elif args.format == "full-lines":
453+
print_full_lines(paths, lines, use_color, prefix=prefix)
454+
else:
455+
raise ValueError(args.format)
271456

272457

273458
if __name__ == "__main__":

0 commit comments

Comments
 (0)