CLI output: docs, review fixes, changelog !471

merged merged by cmc on 2026-09-24 15:08 UTC · krz/gitbay:cli-output-docs into main

24 files changed, +516 −278

Layout: unified · split

.gitbay/wiki/Admin.org +4
@@ -39,6 +39,10 @@ the unit's =AmbientCapabilities=CAP_NET_BIND_SERVICE= covers ports
39 sshd requires that binary to be root-owned and not group/world 39 sshd requires that binary to be root-owned and not group/world
40 writable. Unknown keys fail authentication inside sshd, so system mode 40 writable. Unknown keys fail authentication inside sshd, so system mode
41 requires =registration.mode = "closed"= (check-config enforces this). 41 requires =registration.mode = "closed"= (check-config enforces this).
42 Under the forced command the CLI's leading =--term=<cols>[,color]=
43 argument works as is; a client that sets =GITBAY_TERM= instead needs
44 =AcceptEnv GITBAY_TERM= in =sshd_config=, since that env request is
45 handled by the host's sshd, not gitbayd.
42 46
43* Configuration reference 47* Configuration reference
44 48
.gitbay/wiki/Users.org +45 −4
@@ -727,10 +727,9 @@ terminal, and follows these rules so every noun reads the same way.
727 727
728- A list command prints one row per item, tab-separated, no header. 728- A list command prints one row per item, tab-separated, no header.
729 Columns run identifier, state, then description; a trailing column 729 Columns run identifier, state, then description; a trailing column
730 may carry a word (=due 2027-01-01=, =via team=). The =gitbay= CLI pads 730 may carry a word (=due 2027-01-01=, =via team=). Piped, a timestamp in
731 the tabs into aligned columns when stdout is a terminal and leaves 731 a row is RFC3339 to the second, UTC. Under =--json= nothing is
732 them as tabs when piped, so =cut -f= sees the same bytes stock ssh 732 touched.
733 prints. Under =--json= nothing is touched.
734- An empty list prints nothing on stdout and =nothing to list= on 733- An empty list prints nothing on stdout and =nothing to list= on
735 stderr. 734 stderr.
736- A mutation prints one line: verb, object, identifier 735- A mutation prints one line: verb, object, identifier
@@ -748,6 +747,48 @@ terminal, and follows these rules so every noun reads the same way.
748 something to them (a key, a member, a label on an issue), =set= for 747 something to them (a key, a member, a label on an issue), =set= for
749 a value, =revoke= for a credential, =show= and =list= for reads. 748 a value, =revoke= for a credential, =show= and =list= for reads.
750 749
750** At a terminal
751
752The =gitbay= CLI sends a leading =--term=<cols>[,color]= argument on
753the SSH command line when stdout is a terminal (OpenSSH's multiplexed
754sessions, which the CLI uses, do not forward a session's =SetEnv=).
755The argument goes first: the server reads =--term=<v>= only as the
756first argument, and ignores it over HTTP. The server then prints:
757
758- lists under a header, padded, fitted to the width (the title or
759 description column is cut with =…= first), states in colour, ages as
760 =2h ago=, and the next page as a command on stderr (piped output
761 keeps a =next\t<cursor>= row instead);
762- =show= views with a title line, aligned fields, the body rendered
763 from markdown or org, one line per event, and comments under a rule;
764 a sub-table (labels, revisions, and the like) carries a section label
765 line in both terminal and piped output; timestamps as
766 =2026-09-23 23:26 UTC=. =wiki show= piped prints the page source
767 verbatim; the rendered page is terminal-only;
768- help with flag descriptions, defaults, and examples; =gitbay --help=
769 groups commands under WORK, REPOSITORIES, YOU and INSTANCE;
770 =help --json= adds =flags= and =examples=.
771
772=NO_COLOR=, =TERM=dumb= and =--no-color= drop the colour. =show=,
773=diff= and =log= at a terminal go through =$GITBAY_PAGER=, else
774=$PAGER=, else =less= (with =LESS=FRX= when =LESS= is unset); an empty
775=GITBAY_PAGER= turns paging off. Paging never applies to =--follow=,
776=--json=, or piped output.
777
778=GITBAY_TERM=off= stops the CLI sending =--term= at all, for an
779instance older than v1.36.0, which refuses the argument as an unknown
780command.
781
782Stock ssh without the CLI's multiplexing gets the plain output unless
783it passes the same leading argument or sets the environment variable
784sshd is told to accept. OpenSSH parses options after the host, so the
785argument goes after =--=:
786
787#+begin_src sh
788ssh git@gitbay.org -- --term=120,color issue list krz/gitbay
789ssh -o SetEnv=GITBAY_TERM=120,color git@gitbay.org issue list krz/gitbay
790#+end_src
791
751* Scripting 792* Scripting
752 793
753Every read command takes =--json= and emits one envelope: 794Every read command takes =--json= and emits one envelope:
CHANGELOG.org +36
@@ -4,6 +4,42 @@ Versioning follows semver from v0.1.0. Database migrations run
4automatically on daemon start; upgrade notes appear per release when 4automatically on daemon start; upgrade notes appear per release when
5anything beyond "replace the binary and restart" is needed. 5anything beyond "replace the binary and restart" is needed.
6 6
7* v1.36.0 — 2026-09-23
8
9Terminal output for the CLI (#254).
10
11*Upgrade note.* Upgrade the instance before the CLI: an older server
12refuses the CLI's leading =--term= argument as an unknown command.
13Against an older instance, set =GITBAY_TERM=off= and the CLI sends no
14terminal size.
15
16- At a terminal, lists print under a header, fitted to the width, with
17 states in colour and relative ages; the next page is a command on
18 stderr. Piped output keeps the same tab-separated rows, with
19 timestamps as RFC3339 to the second; =repo log= rows are
20 tab-separated (were fixed-width) and =repo settings show= keys print
21 as fields (=require mr=, not =require_mr:=).
22- =show= commands print a title line, aligned fields, the body
23 rendered from markdown or org, events one per line and comments
24 under a rule, through a pager when longer than the screen.
25- Help describes every flag, with defaults and examples, and
26 =gitbay --help= groups the commands under WORK, REPOSITORIES, YOU
27 and INSTANCE. =help --json= adds =flags= and =examples=.
28- =release list= takes =--limit= and =--cursor=, and leaves the title
29 empty when it repeats the tag. =mr revisions='s one-revision note
30 moved to stderr. =notifications device add= prints =registered
31 device <n>=. =dashboard= prints =none= under an empty section.
32
33The CLI sends =--term=<cols>[,color]= as the first argument on the
34SSH command line, since OpenSSH's multiplexed sessions do not forward
35a session's =SetEnv=; the server reads it only there, and ignores it
36over HTTP. Stock ssh opts in with =ssh git@gitbay.org -- --term=120,color
37issue list krz/gitbay= (the =--= keeps ssh from reading it as its own
38option) or =-o SetEnv=GITBAY_TERM=120,color=. Operators running
39the system-sshd forced command add =AcceptEnv GITBAY_TERM= to
40=sshd_config= for the =SetEnv= form; the =--term= argument needs no
41sshd configuration.
42
7* v1.35.1 — 2026-09-23 43* v1.35.1 — 2026-09-23
8 44
9The landing page's recording (#253). 45The landing page's recording (#253).
cmd/gitbay/ssh.go +3 −2
@@ -114,9 +114,10 @@ var noColor bool
114 114
115// termValue is GITBAY_TERM for this invocation: the terminal's width, 115// termValue is GITBAY_TERM for this invocation: the terminal's width,
116// and whether colour is wanted. Empty when stdout is not a terminal, 116// and whether colour is wanted. Empty when stdout is not a terminal,
117// so piped output stays the rows stock ssh prints. 117// so piped output stays the rows stock ssh prints, and when GITBAY_TERM
118// is "off", for an instance older than --term.
118func termValue(isTerminal bool, cols int, env func(string) string) string { 119func termValue(isTerminal bool, cols int, env func(string) string) string {
119 if !isTerminal || cols < 40 { 120 if !isTerminal || cols < 40 || env("GITBAY_TERM") == "off" {
120 return "" 121 return ""
121 } 122 }
122 v := strconv.Itoa(cols) 123 v := strconv.Itoa(cols)
cmd/gitbay/summaries_gen.go +231 −231
@@ -3,236 +3,236 @@
3package main 3package main
4 4
5var summaries = map[string]string{ 5var summaries = map[string]string{
6 "account export": "write your account bundle (profile, repos, issues, MRs) as JSON", 6 "account export": "write your account bundle (profile, repos, issues, MRs) as JSON",
7 "account import-bundle": "replay an account bundle (see gitbay migrate)", 7 "account import-bundle": "replay an account bundle (see gitbay migrate)",
8 "admin email verify": "mark an address verified by admin assertion", 8 "admin email verify": "mark an address verified by admin assertion",
9 "admin invite": "issue a registration invite and mail its code", 9 "admin invite": "issue a registration invite and mail its code",
10 "admin mr prune": "drop merged or closed MRs' head refs and the objects only they kept, e.g. after a history rewrite (instance admins; audited)", 10 "admin mr prune": "drop merged or closed MRs' head refs and the objects only they kept, e.g. after a history rewrite (instance admins; audited)",
11 "admin repo archive": "archive any repository (instance admins; audited)", 11 "admin repo archive": "archive any repository (instance admins; audited)",
12 "admin repo delete": "delete any repository (instance admins; audited)", 12 "admin repo delete": "delete any repository (instance admins; audited)",
13 "admin repo list": "list every repository with size and last push (instance admins)", 13 "admin repo list": "list every repository with size and last push (instance admins)",
14 "admin repo unarchive": "unarchive any repository (instance admins; audited)", 14 "admin repo unarchive": "unarchive any repository (instance admins; audited)",
15 "admin repo visibility": "set any repository's visibility (instance admins; audited)", 15 "admin repo visibility": "set any repository's visibility (instance admins; audited)",
16 "admin runners forget": "alias of admin runners remove", 16 "admin runners forget": "alias of admin runners remove",
17 "admin runners remove": "drop a key's runner heartbeat row, e.g. one that polled once by mistake (instance admins)", 17 "admin runners remove": "drop a key's runner heartbeat row, e.g. one that polled once by mistake (instance admins)",
18 "admin runners": "the build queue and runner accounts: last poll, scope, the build each holds (instance admins)", 18 "admin runners": "the build queue and runner accounts: last poll, scope, the build each holds (instance admins)",
19 "admin stats": "instance statistics: counts and per-repository disk usage", 19 "admin stats": "instance statistics: counts and per-repository disk usage",
20 "admin user create": "create an account, optionally with a key and a verified address (instance admins)", 20 "admin user create": "create an account, optionally with a key and a verified address (instance admins)",
21 "admin user delete": "delete an account that anchors nothing (keys, emails and sessions go with it)", 21 "admin user delete": "delete an account that anchors nothing (keys, emails and sessions go with it)",
22 "admin user demote": "remove instance admin from an account (never the last one)", 22 "admin user demote": "remove instance admin from an account (never the last one)",
23 "admin user disable": "suspend an account: SSH, web sessions and API tokens refused until re-enabled", 23 "admin user disable": "suspend an account: SSH, web sessions and API tokens refused until re-enabled",
24 "admin user enable": "restore a suspended account", 24 "admin user enable": "restore a suspended account",
25 "admin user limits": "show or set an account's repository and storage caps (instance admins)", 25 "admin user limits": "show or set an account's repository and storage caps (instance admins)",
26 "admin user list": "list accounts (instance admins)", 26 "admin user list": "list accounts (instance admins)",
27 "admin user promote": "make an account an instance admin", 27 "admin user promote": "make an account an instance admin",
28 "admin user show": "show an account: keys, emails, orgs, tokens, sessions (instance admins)", 28 "admin user show": "show an account: keys, emails, orgs, tokens, sessions (instance admins)",
29 "audit": "instance audit log (admins)", 29 "audit": "instance audit log (admins)",
30 "build cancel": "withdraw a queued build before a runner claims it", 30 "build cancel": "withdraw a queued build before a runner claims it",
31 "build jobs": "list the jobs a trigger can name", 31 "build jobs": "list the jobs a trigger can name",
32 "build list": "list recent builds", 32 "build list": "list recent builds",
33 "build log": "print a build's log, or follow it until the build ends", 33 "build log": "print a build's log, or follow it until the build ends",
34 "build show": "show one build", 34 "build show": "show one build",
35 "build trigger": "queue a job now (scheduled or not)", 35 "build trigger": "queue a job now (scheduled or not)",
36 "dashboard": "one read for the account dashboard: review queue, assigned and open work, pins, activity, builds", 36 "dashboard": "one read for the account dashboard: review queue, assigned and open work, pins, activity, builds",
37 "email add": "add an address and mail a verification code", 37 "email add": "add an address and mail a verification code",
38 "email list": "list the addresses on your account", 38 "email list": "list the addresses on your account",
39 "email primary": "make a verified address the primary", 39 "email primary": "make a verified address the primary",
40 "email remove": "remove an address; not the primary, nor the last verified one", 40 "email remove": "remove an address; not the primary, nor the last verified one",
41 "email verify": "confirm a verification code", 41 "email verify": "confirm a verification code",
42 "explore": "list public repositories", 42 "explore": "list public repositories",
43 "feed": "activity on repositories you can reach", 43 "feed": "activity on repositories you can reach",
44 "help": "list available commands", 44 "help": "list available commands",
45 "issue assign": "assignees", 45 "issue assign": "assignees",
46 "issue close": "close an issue", 46 "issue close": "close an issue",
47 "issue comment": "comment", 47 "issue comment": "comment",
48 "issue create": "open an issue", 48 "issue create": "open an issue",
49 "issue edit": "edit title or body", 49 "issue edit": "edit title or body",
50 "issue label": "labels", 50 "issue label": "labels",
51 "issue list": "list issues", 51 "issue list": "list issues",
52 "issue milestone": "set or clear an issue's milestone", 52 "issue milestone": "set or clear an issue's milestone",
53 "issue reopen": "reopen an issue", 53 "issue reopen": "reopen an issue",
54 "issue show": "show an issue with comments", 54 "issue show": "show an issue with comments",
55 "issue templates": "list issue templates (.gitbay/issue-template*.md)", 55 "issue templates": "list issue templates (.gitbay/issue-template*.md)",
56 "keys add": "register an SSH public key (authorized_keys format)", 56 "keys add": "register an SSH public key (authorized_keys format)",
57 "keys label": "name a key; an empty label clears it", 57 "keys label": "name a key; an empty label clears it",
58 "keys list": "list registered SSH keys", 58 "keys list": "list registered SSH keys",
59 "keys remove": "remove an SSH key by fingerprint", 59 "keys remove": "remove an SSH key by fingerprint",
60 "label list": "list a repository's labels with colour and use", 60 "label list": "list a repository's labels with colour and use",
61 "label remove": "remove a label from the repository and from every issue and merge request", 61 "label remove": "remove a label from the repository and from every issue and merge request",
62 "label set": "create a label or set its colour", 62 "label set": "create a label or set its colour",
63 "milestone close": "close a milestone", 63 "milestone close": "close a milestone",
64 "milestone create": "create a milestone", 64 "milestone create": "create a milestone",
65 "milestone list": "list milestones with progress", 65 "milestone list": "list milestones with progress",
66 "milestone reopen": "reopen a milestone", 66 "milestone reopen": "reopen a milestone",
67 "mr close": "close without merging", 67 "mr close": "close without merging",
68 "mr comment": "comment", 68 "mr comment": "comment",
69 "mr create": "open a merge request", 69 "mr create": "open a merge request",
70 "mr diff": "show the diff", 70 "mr diff": "show the diff",
71 "mr diff-comment": "comment on a diff line", 71 "mr diff-comment": "comment on a diff line",
72 "mr draft": "mark a merge request as work in progress", 72 "mr draft": "mark a merge request as work in progress",
73 "mr edit": "edit title or body", 73 "mr edit": "edit title or body",
74 "mr label": "labels", 74 "mr label": "labels",
75 "mr list": "list merge requests", 75 "mr list": "list merge requests",
76 "mr merge": "merge", 76 "mr merge": "merge",
77 "mr milestone": "set or clear an MR's milestone", 77 "mr milestone": "set or clear an MR's milestone",
78 "mr range-diff": "what changed between two revisions of a merge request", 78 "mr range-diff": "what changed between two revisions of a merge request",
79 "mr ready": "take the draft mark off, so it can merge", 79 "mr ready": "take the draft mark off, so it can merge",
80 "mr resolve": "resolve a review thread", 80 "mr resolve": "resolve a review thread",
81 "mr retarget": "retarget onto another branch", 81 "mr retarget": "retarget onto another branch",
82 "mr review request": "ask specific people for a review", 82 "mr review request": "ask specific people for a review",
83 "mr review": "review", 83 "mr review": "review",
84 "mr revisions": "the heads a merge request has had", 84 "mr revisions": "the heads a merge request has had",
85 "mr show": "show a merge request", 85 "mr show": "show a merge request",
86 "mr threads": "review threads on an MR", 86 "mr threads": "review threads on an MR",
87 "mr unresolve": "reopen a review thread", 87 "mr unresolve": "reopen a review thread",
88 "notifications device add": "register an Apple device for push, token on stdin", 88 "notifications device add": "register an Apple device for push, token on stdin",
89 "notifications device list": "your registered devices", 89 "notifications device list": "your registered devices",
90 "notifications device remove": "deregister a device", 90 "notifications device remove": "deregister a device",
91 "notifications list": "your notification inbox, newest first", 91 "notifications list": "your notification inbox, newest first",
92 "notifications read": "mark notifications read", 92 "notifications read": "mark notifications read",
93 "notifications settings mail": "activity by mail as well as the inbox (login links are unaffected)", 93 "notifications settings mail": "activity by mail as well as the inbox (login links are unaffected)",
94 "notifications settings push": "activity on your registered devices as well as the inbox", 94 "notifications settings push": "activity on your registered devices as well as the inbox",
95 "notifications settings show": "your notification preferences", 95 "notifications settings show": "your notification preferences",
96 "notifications settings watch": "every issue and merge request on repositories you can write to", 96 "notifications settings watch": "every issue and merge request on repositories you can write to",
97 "org create": "create an organization (you become its first admin)", 97 "org create": "create an organization (you become its first admin)",
98 "org delete": "delete an empty organization", 98 "org delete": "delete an empty organization",
99 "org label list": "list an org's labels with use across the repositories you can read", 99 "org label list": "list an org's labels with use across the repositories you can read",
100 "org label remove": "remove an org label from the org and from every issue under it", 100 "org label remove": "remove an org label from the org and from every issue under it",
101 "org label set": "create an org label every org repository sees, or set its colour; folds in same-named repo labels", 101 "org label set": "create an org label every org repository sees, or set its colour; folds in same-named repo labels",
102 "org list": "list organizations you belong to", 102 "org list": "list organizations you belong to",
103 "org members add": "add or update a member", 103 "org members add": "add or update a member",
104 "org members list": "list members", 104 "org members list": "list members",
105 "org members remove": "remove a member", 105 "org members remove": "remove a member",
106 "org milestone close": "close an org milestone", 106 "org milestone close": "close an org milestone",
107 "org milestone create": "create an org milestone spanning every org repository; folds in same-titled repo milestones", 107 "org milestone create": "create an org milestone spanning every org repository; folds in same-titled repo milestones",
108 "org milestone list": "list an org's milestones with progress across the repositories you can read", 108 "org milestone list": "list an org's milestones with progress across the repositories you can read",
109 "org milestone reopen": "reopen an org milestone", 109 "org milestone reopen": "reopen an org milestone",
110 "org profile": "show or set an org's profile", 110 "org profile": "show or set an org's profile",
111 "org rename": "rename an organization", 111 "org rename": "rename an organization",
112 "org settings members-role": "role plain membership implies on every org repo", 112 "org settings members-role": "role plain membership implies on every org repo",
113 "org show": "show an organization and its members", 113 "org show": "show an organization and its members",
114 "org team add": "add org members to a team", 114 "org team add": "add org members to a team",
115 "org team create": "create a team", 115 "org team create": "create a team",
116 "org team delete": "delete a team (its grants with it)", 116 "org team delete": "delete a team (its grants with it)",
117 "org team grant": "grant a team a role on an org repo", 117 "org team grant": "grant a team a role on an org repo",
118 "org team list": "list an org's teams", 118 "org team list": "list an org's teams",
119 "org team remove": "remove members from a team", 119 "org team remove": "remove members from a team",
120 "org team revoke": "revoke a team's grant", 120 "org team revoke": "revoke a team's grant",
121 "org team show": "show a team's members and grants", 121 "org team show": "show a team's members and grants",
122 "pgp add": "register an OpenPGP public key (armored)", 122 "pgp add": "register an OpenPGP public key (armored)",
123 "pgp list": "list registered OpenPGP keys", 123 "pgp list": "list registered OpenPGP keys",
124 "pgp remove": "remove an OpenPGP key by fingerprint", 124 "pgp remove": "remove an OpenPGP key by fingerprint",
125 "profile set": "set your profile", 125 "profile set": "set your profile",
126 "profile show": "show a user's or org's profile", 126 "profile show": "show a user's or org's profile",
127 "register": "create an account (only meaningful for unregistered keys)", 127 "register": "create an account (only meaningful for unregistered keys)",
128 "release asset add": "upload an asset from stdin", 128 "release asset add": "upload an asset from stdin",
129 "release asset get": "write an asset to stdout", 129 "release asset get": "write an asset to stdout",
130 "release asset remove": "remove an asset", 130 "release asset remove": "remove an asset",
131 "release create": "create a release on a tag", 131 "release create": "create a release on a tag",
132 "release delete": "delete a release and its assets", 132 "release delete": "delete a release and its assets",
133 "release edit": "update a release's title and notes", 133 "release edit": "update a release's title and notes",
134 "release list": "list releases", 134 "release list": "list releases",
135 "release show": "show a release with assets", 135 "release show": "show a release with assets",
136 "repo access grant": "grant access", 136 "repo access grant": "grant access",
137 "repo access list": "list who can reach the repository, with the role and where it comes from", 137 "repo access list": "list who can reach the repository, with the role and where it comes from",
138 "repo access revoke": "revoke access", 138 "repo access revoke": "revoke access",
139 "repo archive": "archive a repository (read-only: pushes and issue/MR writes refused)", 139 "repo archive": "archive a repository (read-only: pushes and issue/MR writes refused)",
140 "repo blame": "attribute lines to commits", 140 "repo blame": "attribute lines to commits",
141 "repo bookmark": "bookmark a repository to come back to", 141 "repo bookmark": "bookmark a repository to come back to",
142 "repo bookmarks": "list the repositories you have bookmarked", 142 "repo bookmarks": "list the repositories you have bookmarked",
143 "repo cat": "read a file", 143 "repo cat": "read a file",
144 "repo commit": "show one commit with its patch", 144 "repo commit": "show one commit with its patch",
145 "repo commit-file": "write a file and commit it", 145 "repo commit-file": "write a file and commit it",
146 "repo create": "create a repository", 146 "repo create": "create a repository",
147 "repo delete": "delete a repository", 147 "repo delete": "delete a repository",
148 "repo deploy-key add": "bind a read-only (or --rw) key to one repository", 148 "repo deploy-key add": "bind a read-only (or --rw) key to one repository",
149 "repo deploy-key list": "list deploy keys", 149 "repo deploy-key list": "list deploy keys",
150 "repo deploy-key remove": "remove a deploy key", 150 "repo deploy-key remove": "remove a deploy key",
151 "repo deps disable": "stop checking dependencies", 151 "repo deps disable": "stop checking dependencies",
152 "repo deps enable": "check dependencies for updates", 152 "repo deps enable": "check dependencies for updates",
153 "repo deps status": "show dependency check state", 153 "repo deps status": "show dependency check state",
154 "repo diff": "the patch between two refs, from their merge base", 154 "repo diff": "the patch between two refs, from their merge base",
155 "repo domain add": "claim a custom pages domain (verify with a DNS TXT record)", 155 "repo domain add": "claim a custom pages domain (verify with a DNS TXT record)",
156 "repo domain list": "list custom pages domains", 156 "repo domain list": "list custom pages domains",
157 "repo domain remove": "remove a custom pages domain", 157 "repo domain remove": "remove a custom pages domain",
158 "repo domain verify": "check the DNS challenge and activate a claim", 158 "repo domain verify": "check the DNS challenge and activate a claim",
159 "repo download": "write a tar.gz of a ref to stdout", 159 "repo download": "write a tar.gz of a ref to stdout",
160 "repo fork": "fork a repository under your account", 160 "repo fork": "fork a repository under your account",
161 "repo grep": "search file contents", 161 "repo grep": "search file contents",
162 "repo import": "server-side mirror of a foreign repository", 162 "repo import": "server-side mirror of a foreign repository",
163 "repo import-issues": "import issue and PR history from GitHub or Forgejo", 163 "repo import-issues": "import issue and PR history from GitHub or Forgejo",
164 "repo list": "list repositories you own or can access", 164 "repo list": "list repositories you own or can access",
165 "repo log": "commit log with signature states", 165 "repo log": "commit log with signature states",
166 "repo mirror add": "mirror to or from a remote", 166 "repo mirror add": "mirror to or from a remote",
167 "repo mirror list": "list mirrors with sync status", 167 "repo mirror list": "list mirrors with sync status",
168 "repo mirror remove": "remove a mirror", 168 "repo mirror remove": "remove a mirror",
169 "repo mirror sync": "schedule an immediate sync", 169 "repo mirror sync": "schedule an immediate sync",
170 "repo mute": "mute a repository, including work you are part of", 170 "repo mute": "mute a repository, including work you are part of",
171 "repo pin": "pin a repository to your dashboard", 171 "repo pin": "pin a repository to your dashboard",
172 "repo refs": "list branches and tags", 172 "repo refs": "list branches and tags",
173 "repo rename": "rename a repository", 173 "repo rename": "rename a repository",
174 "repo runner add": "attach a runner's public key to a repository", 174 "repo runner add": "attach a runner's public key to a repository",
175 "repo runner list": "list the runners attached to a repository", 175 "repo runner list": "list the runners attached to a repository",
176 "repo runner remove": "detach a runner from a repository", 176 "repo runner remove": "detach a runner from a repository",
177 "repo search": "find repositories by name, description, or topic", 177 "repo search": "find repositories by name, description, or topic",
178 "repo secret list": "list build secret names", 178 "repo secret list": "list build secret names",
179 "repo secret remove": "remove a build secret", 179 "repo secret remove": "remove a build secret",
180 "repo secret set": "set a build secret", 180 "repo secret set": "set a build secret",
181 "repo settings default-branch": "set the default branch", 181 "repo settings default-branch": "set the default branch",
182 "repo settings description": "set the repository description", 182 "repo settings description": "set the repository description",
183 "repo settings git-daemon": "expose over git://", 183 "repo settings git-daemon": "expose over git://",
184 "repo settings protect": "protect a branch", 184 "repo settings protect": "protect a branch",
185 "repo settings protect-tag": "protect tags matching a glob (created once, never moved or deleted)", 185 "repo settings protect-tag": "protect tags matching a glob (created once, never moved or deleted)",
186 "repo settings require-approvals": "require N fresh approvals to merge", 186 "repo settings require-approvals": "require N fresh approvals to merge",
187 "repo settings require-checks": "gate merges on green statuses", 187 "repo settings require-checks": "gate merges on green statuses",
188 "repo settings require-codeowners": "require an owner's approval for every file CODEOWNERS covers", 188 "repo settings require-codeowners": "require an owner's approval for every file CODEOWNERS covers",
189 "repo settings require-mr": "protected branches take changes through merge requests only", 189 "repo settings require-mr": "protected branches take changes through merge requests only",
190 "repo settings require-resolved": "require all review threads resolved to merge", 190 "repo settings require-resolved": "require all review threads resolved to merge",
191 "repo settings require-signed": "require verified commit signatures", 191 "repo settings require-signed": "require verified commit signatures",
192 "repo settings show": "show settings", 192 "repo settings show": "show settings",
193 "repo settings unprotect": "unprotect a branch", 193 "repo settings unprotect": "unprotect a branch",
194 "repo settings unprotect-tag": "drop a protected-tag glob", 194 "repo settings unprotect-tag": "drop a protected-tag glob",
195 "repo settings visibility": "set repository visibility", 195 "repo settings visibility": "set repository visibility",
196 "repo settings website": "set the repository website", 196 "repo settings website": "set the repository website",
197 "repo show": "show repository details", 197 "repo show": "show repository details",
198 "repo topics add": "add topics", 198 "repo topics add": "add topics",
199 "repo topics remove": "remove topics", 199 "repo topics remove": "remove topics",
200 "repo topics": "list topics", 200 "repo topics": "list topics",
201 "repo transfer": "move a repository to another owner", 201 "repo transfer": "move a repository to another owner",
202 "repo tree": "list a directory", 202 "repo tree": "list a directory",
203 "repo unarchive": "unarchive a repository", 203 "repo unarchive": "unarchive a repository",
204 "repo unbookmark": "remove a bookmark", 204 "repo unbookmark": "remove a bookmark",
205 "repo unpin": "unpin a repository", 205 "repo unpin": "unpin a repository",
206 "repo unwatch": "back to the default: only work you are part of", 206 "repo unwatch": "back to the default: only work you are part of",
207 "repo watch": "hear about all activity on a repository", 207 "repo watch": "hear about all activity on a repository",
208 "runner done": "finish a build", 208 "runner done": "finish a build",
209 "runner log": "append a build's log from stdin", 209 "runner log": "append a build's log from stdin",
210 "runner next": "claim the oldest pending build this key may run (runner protocol)", 210 "runner next": "claim the oldest pending build this key may run (runner protocol)",
211 "search": "find repositories, issues and merge requests across the instance", 211 "search": "find repositories, issues and merge requests across the instance",
212 "snippet create": "create a snippet from one file on stdin", 212 "snippet create": "create a snippet from one file on stdin",
213 "snippet delete": "delete a snippet and its files", 213 "snippet delete": "delete a snippet and its files",
214 "snippet edit": "change a snippet's description or visibility", 214 "snippet edit": "change a snippet's description or visibility",
215 "snippet file get": "write a snippet file to stdout", 215 "snippet file get": "write a snippet file to stdout",
216 "snippet file remove": "remove a file from a snippet", 216 "snippet file remove": "remove a file from a snippet",
217 "snippet file set": "add a file to a snippet, or replace one, from stdin", 217 "snippet file set": "add a file to a snippet, or replace one, from stdin",
218 "snippet list": "list your snippets, or an owner's public ones", 218 "snippet list": "list your snippets, or an owner's public ones",
219 "snippet show": "show a snippet's metadata and files", 219 "snippet show": "show a snippet's metadata and files",
220 "status list": "statuses on a commit", 220 "status list": "statuses on a commit",
221 "status set": "report a commit status (CI)", 221 "status set": "report a commit status (CI)",
222 "token create": "mint an API token (shown once)", 222 "token create": "mint an API token (shown once)",
223 "token list": "list API tokens", 223 "token list": "list API tokens",
224 "token revoke": "revoke an API token by name", 224 "token revoke": "revoke an API token by name",
225 "web login": "mint a one-time browser login URL", 225 "web login": "mint a one-time browser login URL",
226 "web sessions list": "list your browser sessions", 226 "web sessions list": "list your browser sessions",
227 "web sessions revoke": "end a browser session, or all of them", 227 "web sessions revoke": "end a browser session, or all of them",
228 "web theme set": "follow the browser's scheme, or force light or dark", 228 "web theme set": "follow the browser's scheme, or force light or dark",
229 "web theme show": "the colour scheme the web UI uses for you", 229 "web theme show": "the colour scheme the web UI uses for you",
230 "webhook add": "add a webhook", 230 "webhook add": "add a webhook",
231 "webhook deliveries": "recent deliveries", 231 "webhook deliveries": "recent deliveries",
232 "webhook list": "list webhooks", 232 "webhook list": "list webhooks",
233 "webhook redeliver": "queue a delivery again", 233 "webhook redeliver": "queue a delivery again",
234 "webhook remove": "remove a webhook", 234 "webhook remove": "remove a webhook",
235 "whoami": "show the authenticated account", 235 "whoami": "show the authenticated account",
236 "wiki list": "list a repository's wiki pages", 236 "wiki list": "list a repository's wiki pages",
237 "wiki show": "print a wiki page", 237 "wiki show": "print a wiki page",
238} 238}
cmd/gitbay/summaries_test.go +7 −2
@@ -3,6 +3,7 @@ package main
3import ( 3import (
4 "flag" 4 "flag"
5 "fmt" 5 "fmt"
6 "go/format"
6 "os" 7 "os"
7 "slices" 8 "slices"
8 "strings" 9 "strings"
@@ -25,11 +26,15 @@ func TestSummariesAreCurrent(t *testing.T) {
25 slices.Sort(lines) 26 slices.Sort(lines)
26 b.WriteString(strings.Join(lines, "")) 27 b.WriteString(strings.Join(lines, ""))
27 b.WriteString("}\n") 28 b.WriteString("}\n")
29 want, err := format.Source([]byte(b.String()))
30 if err != nil {
31 t.Fatal(err)
32 }
28 if *updateSummaries { 33 if *updateSummaries {
29 os.WriteFile("summaries_gen.go", []byte(b.String()), 0o644) 34 os.WriteFile("summaries_gen.go", want, 0o644)
30 } 35 }
31 got, _ := os.ReadFile("summaries_gen.go") 36 got, _ := os.ReadFile("summaries_gen.go")
32 if string(got) != b.String() { 37 if string(got) != string(want) {
33 t.Fatal("summaries_gen.go is stale: go test ./cmd/gitbay -run TestSummariesAreCurrent -update") 38 t.Fatal("summaries_gen.go is stale: go test ./cmd/gitbay -run TestSummariesAreCurrent -update")
34 } 39 }
35} 40}
cmd/gitbay/term_test.go +1
@@ -22,6 +22,7 @@ func TestTermValue(t *testing.T) {
22 {true, 120, map[string]string{"NO_COLOR": "1"}, false, "120"}, 22 {true, 120, map[string]string{"NO_COLOR": "1"}, false, "120"},
23 {true, 120, map[string]string{"TERM": "dumb"}, false, "120"}, 23 {true, 120, map[string]string{"TERM": "dumb"}, false, "120"},
24 {true, 120, nil, true, "120"}, 24 {true, 120, nil, true, "120"},
25 {true, 120, map[string]string{"GITBAY_TERM": "off"}, false, ""},
25 } 26 }
26 for _, c := range cases { 27 for _, c := range cases {
27 noColor = c.noColor 28 noColor = c.noColor
e2e/readonly_test.go +3 −1
@@ -232,8 +232,10 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
232 } 232 }
233} 233}
234 234
235var sgrRe = regexp.MustCompile("\x1b\\[[0-9;]*m")
236
235func stripSGRe2e(s string) string { 237func stripSGRe2e(s string) string {
236 return regexp.MustCompile("\x1b\\[[0-9;]*m").ReplaceAllString(s, "") 238 return sgrRe.ReplaceAllString(s, "")
237} 239}
238 240
239func displayCells(s string) int { 241func displayCells(s string) int {
internal/control/adminhost.go +1 −1
@@ -333,7 +333,7 @@ func runAdminStats(c *Ctx, args []string) int {
333 "lfs", humanBytes(d.LFSBytes), 333 "lfs", humanBytes(d.LFSBytes),
334 ) 334 )
335 if len(d.Repos) > 0 { 335 if len(d.Repos) > 0 {
336 io.WriteString(w, "\n") 336 v.section("repos")
337 tb := c.table(w, "PATH", "BYTES") 337 tb := c.table(w, "PATH", "BYTES")
338 for _, r := range d.Repos { 338 for _, r := range d.Repos {
339 tb.row(cRef(r.Path), cText(humanBytes(r.Bytes))) 339 tb.row(cRef(r.Path), cText(humanBytes(r.Bytes)))
internal/control/control.go +8 −13
@@ -123,17 +123,16 @@ func Dispatch(c *Ctx, argv []string) int {
123 // A leading --term=<v> selects terminal output for this session, the 123 // A leading --term=<v> selects terminal output for this session, the
124 // same as GITBAY_TERM. It must come off before Lookup: Lookup matches 124 // same as GITBAY_TERM. It must come off before Lookup: Lookup matches
125 // argv against a command's Path, and a --term= in front would never 125 // argv against a command's Path, and a --term= in front would never
126 // match one. 126 // match one. Over HTTP it is dropped unread: the web and the API
127 for len(argv) > 0 { 127 // render no terminal.
128 v, ok := strings.CutPrefix(argv[0], "--term=") 128 if v, ok := strings.CutPrefix(argv[0], "--term="); ok {
129 if !ok { 129 if !c.ViaAPI {
130 break 130 c.Term = ParseTerm(v)
131 } 131 }
132 c.Term = ParseTerm(v)
133 argv = argv[1:] 132 argv = argv[1:]
134 } 133 if len(argv) == 0 {
135 if len(argv) == 0 { 134 return c.fail(protocol.ExitUsage, "no command given; try: ssh <host> help")
136 return c.fail(protocol.ExitUsage, "no command given; try: ssh <host> help") 135 }
137 } 136 }
138 cmd, rest, ok := Lookup(argv) 137 cmd, rest, ok := Lookup(argv)
139 c.Cmd = cmd 138 c.Cmd = cmd
@@ -149,10 +148,6 @@ func Dispatch(c *Ctx, argv []string) int {
149 c.JSON = true 148 c.JSON = true
150 continue 149 continue
151 } 150 }
152 if v, ok := strings.CutPrefix(a, "--term="); ok {
153 c.Term = ParseTerm(v)
154 continue
155 }
156 args = append(args, a) 151 args = append(args, a)
157 } 152 }
158 c.Argv = args 153 c.Argv = args
internal/control/control_test.go +26
@@ -276,3 +276,29 @@ func TestArgumentRefusalsNameTheUsage(t *testing.T) {
276 } 276 }
277 } 277 }
278} 278}
279
280// TestTermArgument: --term= is read only as the first argument, and
281// never over HTTP.
282func TestTermArgument(t *testing.T) {
283 cases := []struct {
284 name string
285 viaAPI bool
286 argv []string
287 want Term
288 wantArgv []string
289 }{
290 {"leading", false, []string{"--term=80,color", "issue", "list", "a/b"}, Term{Cols: 80, Color: true}, []string{"a/b"}},
291 {"later", false, []string{"issue", "list", "a/b", "--term=x"}, Term{}, []string{"a/b", "--term=x"}},
292 {"over HTTP", true, []string{"--term=80,color", "issue", "list", "a/b"}, Term{}, []string{"a/b"}},
293 }
294 for _, tc := range cases {
295 c := &Ctx{Scope: "git", ViaAPI: tc.viaAPI, Stdout: io.Discard, Stderr: io.Discard}
296 Dispatch(c, tc.argv)
297 if c.Term != tc.want {
298 t.Errorf("%s: Term %+v, want %+v", tc.name, c.Term, tc.want)
299 }
300 if !slices.Equal(c.Argv, tc.wantArgv) {
301 t.Errorf("%s: Argv %q, want %q", tc.name, c.Argv, tc.wantArgv)
302 }
303 }
304}
internal/control/deps.go +1 −1
@@ -122,7 +122,7 @@ func runDepsStatus(c *Ctx, args []string) int {
122 "tracked in", tracked, 122 "tracked in", tracked,
123 ) 123 )
124 if len(out.Behind) > 0 { 124 if len(out.Behind) > 0 {
125 io.WriteString(w, "\n") 125 v.section("behind")
126 tb := c.table(w, "ECOSYSTEM", "NAME", "CURRENT", "LATEST") 126 tb := c.table(w, "ECOSYSTEM", "NAME", "CURRENT", "LATEST")
127 for _, b := range out.Behind { 127 for _, b := range out.Behind {
128 tb.row(cText(b.Ecosystem), cRef(b.Name), cText(b.Current), cText(b.Latest)) 128 tb.row(cText(b.Ecosystem), cRef(b.Name), cText(b.Current), cText(b.Latest))
internal/control/help.go +2 −1
@@ -112,7 +112,8 @@ func runHelp(c *Ctx, args []string) int {
112} 112}
113 113
114// program is how help spells the command it documents: the CLI at a 114// program is how help spells the command it documents: the CLI at a
115// terminal (only the CLI sends GITBAY_TERM), ssh otherwise. 115// terminal (only the CLI or a caller passing --term sets one), ssh
116// otherwise.
116func (c *Ctx) program() string { 117func (c *Ctx) program() string {
117 if c.Term.Cols > 0 { 118 if c.Term.Cols > 0 {
118 return "gitbay" 119 return "gitbay"
internal/control/mr.go +1 −1
@@ -790,7 +790,7 @@ func runMRShow(c *Ctx, args []string) int {
790 v.body(d.Body, d.BodyFormat) 790 v.body(d.Body, d.BodyFormat)
791 791
792 if len(commits) > 1 { 792 if len(commits) > 1 {
793 io.WriteString(w, "\n") 793 v.section("commit")
794 tb := c.table(w, "SHA", "SUBJECT") 794 tb := c.table(w, "SHA", "SUBJECT")
795 for _, cm := range commits { 795 for _, cm := range commits {
796 tb.row(cRef(fmt.Sprintf("%.10s", cm.SHA)), cFlex(cm.Subject)) 796 tb.row(cRef(fmt.Sprintf("%.10s", cm.SHA)), cFlex(cm.Subject))
internal/control/org.go +1 −1
@@ -140,7 +140,7 @@ func runOrgShow(c *Ctx, args []string) int {
140 v := c.view(w) 140 v := c.view(w)
141 v.title(d.Org, "", "") 141 v.title(d.Org, "", "")
142 if len(ms) > 0 { 142 if len(ms) > 0 {
143 io.WriteString(w, "\n") 143 v.section("members")
144 tb := c.table(w, "USER", "ROLE") 144 tb := c.table(w, "USER", "ROLE")
145 for _, m := range ms { 145 for _, m := range ms {
146 tb.row(cRef(m.User), cState(m.Role)) 146 tb.row(cRef(m.User), cState(m.Role))
internal/control/release.go +1 −1
@@ -310,7 +310,7 @@ func runReleaseShow(c *Ctx, args []string) int {
310 ) 310 )
311 v.body(d.Notes, d.NotesFormat) 311 v.body(d.Notes, d.NotesFormat)
312 if len(d.Assets) > 0 { 312 if len(d.Assets) > 0 {
313 io.WriteString(w, "\n") 313 v.section("assets")
314 tb := c.table(w, "NAME", "SIZE", "SHA256") 314 tb := c.table(w, "NAME", "SIZE", "SHA256")
315 for _, a := range d.Assets { 315 for _, a := range d.Assets {
316 tb.row(cRef(a.Name), cNum(a.Size), cFlex(a.SHA256)) 316 tb.row(cRef(a.Name), cNum(a.Size), cFlex(a.SHA256))
internal/control/snippet.go +1 −1
@@ -231,7 +231,7 @@ func runSnippetShow(c *Ctx, args []string) int {
231 "url", snippetURL(c, sn), 231 "url", snippetURL(c, sn),
232 ) 232 )
233 if len(files) > 0 { 233 if len(files) > 0 {
234 io.WriteString(w, "\n") 234 v.section("files")
235 tb := c.table(w, "NAME", "SIZE") 235 tb := c.table(w, "NAME", "SIZE")
236 for _, f := range files { 236 for _, f := range files {
237 tb.row(cRef(f.Name), cText(fmt.Sprintf("%d bytes", f.Size))) 237 tb.row(cRef(f.Name), cText(fmt.Sprintf("%d bytes", f.Size)))
internal/control/table.go +1
@@ -61,6 +61,7 @@ func (t *table) row(cs ...cell) {
61 } 61 }
62 now := termNow() 62 now := termNow()
63 for i := range cs { 63 for i := range cs {
64 cs[i].s = termSafe(cs[i].s)
64 if cs[i].kind == kindAge { 65 if cs[i].kind == kindAge {
65 cs[i].s = relAge(cs[i].s, now) 66 cs[i].s = relAge(cs[i].s, now)
66 } 67 }
internal/control/teams.go +1 −1
@@ -190,7 +190,7 @@ func runTeamShow(c *Ctx, args []string) int {
190 v.title(org.Name+"/"+team.Name, "", "") 190 v.title(org.Name+"/"+team.Name, "", "")
191 v.fields("members", strings.Join(members, ", ")) 191 v.fields("members", strings.Join(members, ", "))
192 if len(grants) > 0 { 192 if len(grants) > 0 {
193 io.WriteString(w, "\n") 193 v.section("grants")
194 tb := c.table(w, "REPO", "ROLE") 194 tb := c.table(w, "REPO", "ROLE")
195 for _, g := range grants { 195 for _, g := range grants {
196 tb.row(cRef(g.RepoPath), cState(g.Role)) 196 tb.row(cRef(g.RepoPath), cState(g.Role))
internal/control/term.go +38 −7
@@ -37,15 +37,46 @@ func ParseTerm(v string) Term {
37} 37}
38 38
39const ( 39const (
40 sgrReset = "\x1b[0m" 40 sgrReset = "\x1b[0m"
41 sgrBold = "\x1b[1m" 41 sgrBold = "\x1b[1m"
42 sgrDim = "\x1b[2m" 42 sgrDim = "\x1b[2m"
43 sgrUnderline = "\x1b[4m" 43 sgrRed = "\x1b[31m"
44 sgrRed = "\x1b[31m" 44 sgrGreen = "\x1b[32m"
45 sgrGreen = "\x1b[32m" 45 sgrMagenta = "\x1b[35m"
46 sgrMagenta = "\x1b[35m"
47) 46)
48 47
48// termSafe replaces the bytes a terminal would act on — ESC, the C0
49// controls but tab and newline, DEL, and the C1 controls — with U+FFFD,
50// so user text cannot move the cursor, set the clipboard (OSC 52) or
51// clear the screen. Carriage return is dropped rather than replaced:
52// web forms store CRLF line endings, and a lone CR would let text
53// overwrite its own line. Terminal output only: plain output is unchanged.
54func termSafe(s string) string {
55 unsafe := func(r rune) bool {
56 return (r < 0x20 && r != '\t' && r != '\n') || (r >= 0x7f && r <= 0x9f)
57 }
58 if strings.IndexFunc(s, unsafe) < 0 {
59 return s
60 }
61 return strings.Map(func(r rune) rune {
62 switch {
63 case r == '\r':
64 return -1
65 case unsafe(r):
66 return '\uFFFD'
67 }
68 return r
69 }, s)
70}
71
72// safe is termSafe at a terminal and s unchanged in plain output.
73func (t Term) safe(s string) string {
74 if t.Cols == 0 {
75 return s
76 }
77 return termSafe(s)
78}
79
49// paint wraps s in an SGR sequence when colour is on. 80// paint wraps s in an SGR sequence when colour is on.
50func (t Term) paint(sgr, s string) string { 81func (t Term) paint(sgr, s string) string {
51 if !t.Color || sgr == "" || s == "" { 82 if !t.Color || sgr == "" || s == "" {
internal/control/term_test.go +6 −6
@@ -26,12 +26,12 @@ func TestParseTerm(t *testing.T) {
26 26
27func TestCells(t *testing.T) { 27func TestCells(t *testing.T) {
28 cases := map[string]int{ 28 cases := map[string]int{
29 "abc": 3, 29 "abc": 3,
30 "日本": 4, 30 "日本": 4,
31 "é": 1, 31 "é": 1,
32 "é": 1, 32 "é": 1,
33 "\x1b[32mopen\x1b[0m": 4, 33 "\x1b[32mopen\x1b[0m": 4,
34 "": 0, 34 "": 0,
35 } 35 }
36 for in, want := range cases { 36 for in, want := range cases {
37 if got := cells(in); got != want { 37 if got := cells(in); got != want {
internal/control/termsafe_test.go added +89
@@ -0,0 +1,89 @@
1package control
2
3import (
4 "bytes"
5 "strings"
6 "testing"
7
8 "gitbay.org/gitbay/internal/protocol"
9 "gitbay.org/gitbay/internal/store"
10)
11
12const evil = "x\x1b]52;c;ZXZpbA==\x07y\x1b[2Jz"
13
14func TestTermSafe(t *testing.T) {
15 cases := map[string]string{
16 "plain\ttext\n": "plain\ttext\n",
17 "a\x1bb": "a�b",
18 "a\x00c\x7fd": "a�c�d",
19 "a\r\nb\r\n": "a\nb\n",
20 "a\rb": "ab",
21 "a\u0085b\u009bc": "a�b�c",
22 "ümlaut": "ümlaut",
23 }
24 for in, want := range cases {
25 if got := termSafe(in); got != want {
26 t.Errorf("termSafe(%q) = %q, want %q", in, got, want)
27 }
28 }
29}
30
31func noControls(t *testing.T, what, out string) {
32 t.Helper()
33 s := stripSGR(out)
34 if strings.ContainsAny(s, "\x1b\x07") {
35 t.Errorf("%s: control bytes reach the terminal: %q", what, s)
36 }
37}
38
39func TestTableRowControlBytes(t *testing.T) {
40 var plain, term bytes.Buffer
41 for _, c := range []struct {
42 ctx *Ctx
43 w *bytes.Buffer
44 }{{&Ctx{}, &plain}, {&Ctx{Term: Term{Cols: 80, Color: true}}, &term}} {
45 tb := c.ctx.table(c.w, "#", "STATE", "TITLE")
46 tb.row(cRef("#1"), cState("open"), cFlex(evil))
47 tb.flush()
48 }
49 noControls(t, "table", term.String())
50 if want := "#1\topen\t" + evil + "\n"; plain.String() != want {
51 t.Errorf("plain changed: %q", plain.String())
52 }
53}
54
55func TestIssueShowControlBytes(t *testing.T) {
56 show := func(term Term) string {
57 st, repo, uid := newQueueTestRepo(t)
58 if _, err := st.CreateIssue(repo.ID, uid, evil, "body "+evil, "md"); err != nil {
59 t.Fatal(err)
60 }
61 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"})
62 c.Term = term
63 if code := Dispatch(c, []string{"issue", "show", repo.Path(), "1"}); code != protocol.ExitOK {
64 t.Fatalf("exit %d: %s", code, errOut)
65 }
66 return c.Stdout.(*bytes.Buffer).String()
67 }
68 noControls(t, "issue show", show(Term{Cols: 80, Color: true}))
69 if out := show(Term{}); !strings.Contains(out, "#1 "+evil+" open\n") {
70 t.Errorf("plain title changed:\n%q", out)
71 }
72}
73
74// A body written in a browser arrives with CRLF line endings.
75func TestIssueShowCRLFBody(t *testing.T) {
76 st, repo, uid := newQueueTestRepo(t)
77 if _, err := st.CreateIssue(repo.ID, uid, "t", "first line\r\nsecond line\r\n", "md"); err != nil {
78 t.Fatal(err)
79 }
80 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"})
81 c.Term = Term{Cols: 80}
82 if code := Dispatch(c, []string{"issue", "show", repo.Path(), "1"}); code != protocol.ExitOK {
83 t.Fatalf("exit %d: %s", code, errOut)
84 }
85 out := c.Stdout.(*bytes.Buffer).String()
86 if strings.ContainsAny(out, "\r�") || !strings.Contains(out, "first line second line") {
87 t.Errorf("CRLF body at a terminal:\n%q", out)
88 }
89}
internal/control/view.go +7 −4
@@ -70,6 +70,7 @@ func (v *view) section(label string) {
70func (v *view) title(ref, title, state string) { 70func (v *view) title(ref, title, state string) {
71 v.sep() 71 v.sep()
72 t := v.c.Term 72 t := v.c.Term
73 ref, title, state = t.safe(ref), t.safe(title), t.safe(state)
73 if t.Cols == 0 { 74 if t.Cols == 0 {
74 switch { 75 switch {
75 case title == "" && state == "": 76 case title == "" && state == "":
@@ -134,7 +135,7 @@ func (v *view) fields(kv ...string) {
134 } 135 }
135 v.sep() 136 v.sep()
136 for i := 0; i+1 < len(kv); i += 2 { 137 for i := 0; i+1 < len(kv); i += 2 {
137 key, val := kv[i], kv[i+1] 138 key, val := v.c.Term.safe(kv[i]), v.c.Term.safe(kv[i+1])
138 if val == "" { 139 if val == "" {
139 continue 140 continue
140 } 141 }
@@ -159,6 +160,7 @@ func (v *view) body(src, format string) {
159 return 160 return
160 } 161 }
161 v.sep() 162 v.sep()
163 src = v.c.Term.safe(src)
162 for _, line := range strings.Split(strings.TrimRight(termtext.Render(src, format, v.opts()), "\n"), "\n") { 164 for _, line := range strings.Split(strings.TrimRight(termtext.Render(src, format, v.opts()), "\n"), "\n") {
163 if line == "" { 165 if line == "" {
164 io.WriteString(v.w, "\n") 166 io.WriteString(v.w, "\n")
@@ -171,8 +173,8 @@ func (v *view) body(src, format string) {
171// event is one line for a system comment: its text without link 173// event is one line for a system comment: its text without link
172// targets, the time at the right edge at a terminal. 174// targets, the time at the right edge at a terminal.
173func (v *view) event(text, format, ts string) { 175func (v *view) event(text, format, ts string) {
174 line := "· " + termtext.Inline(text, format) 176 line := "· " + termtext.Inline(v.c.Term.safe(text), format)
175 when := v.c.when(ts) 177 when := v.c.Term.safe(v.c.when(ts))
176 if cols := v.c.Term.Cols; cols > 0 { 178 if cols := v.c.Term.Cols; cols > 0 {
177 room := cols - 2 - 2 - cells(when) 179 room := cols - 2 - 2 - cells(when)
178 line = pad(clip(line, room), room) 180 line = pad(clip(line, room), room)
@@ -182,7 +184,8 @@ func (v *view) event(text, format, ts string) {
182} 184}
183 185
184func (v *view) comment(author, ts, body, format string) { 186func (v *view) comment(author, ts, body, format string) {
185 when := v.c.when(ts) 187 when := v.c.Term.safe(v.c.when(ts))
188 author = v.c.Term.safe(author)
186 cols := v.c.Term.Cols 189 cols := v.c.Term.Cols
187 if cols > 0 { 190 if cols > 0 {
188 suffix := ", " + when + " " 191 suffix := ", " + when + " "
internal/termtext/termtext.go +2
@@ -20,6 +20,8 @@ type Options struct {
20 Base string 20 Base string
21} 21}
22 22
23// Render lays out markdown or org for a terminal. It passes control
24// bytes in src through: callers sanitise src first (control.termSafe).
23func Render(src, format string, o Options) string { 25func Render(src, format string, o Options) string {
24 if format == "org" { 26 if format == "org" {
25 return Org(src, o) 27 return Org(src, o)