Commit 02e4d29c3d

02e4d29c3d7167bcf37e8643a5ffb869c44b4abf

parent: 35807c25f1

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-02 04:25 UTC

control: organisation, label, milestone and team commands as screens

org list and show (show also serves org members list), one label
screen for label list and org label list, emitMilestones (repo and org
milestone list) taking its caller's legend, org team list and show.
An open milestone past its due date leads with ●.

Ref #319

Layout: unified · split

internal/control/label.go +21 −1
@@ -38,6 +38,20 @@ func init() {
3838// it is the one that types cleanly there.
3939var labelColorPat = regexp.MustCompile(`^#?[0-9a-fA-F]{6}$`)
4040
41// labelsScreen is a label list at a terminal: each label's name, colour
42// and how much carries it.
43func labelsScreen(labels []store.Label, actions ...action) screen {
44 rows := make([]row, len(labels))
45 for i, l := range labels {
46 org := ""
47 if l.Org {
48 org = "org"
49 }
50 rows[i] = rowOf(cRef(l.Name), cSwatch(l.Color), cMeta(fmt.Sprintf("%d issues", l.Issues), fmt.Sprintf("%d MRs", l.MRs), org))
51 }
52 return listScreen("Labels", rows, actions...)
53}
54
4155func runLabelList(c *Ctx, args []string) int {
4256 if len(args) != 1 {
4357 return c.usage()
@@ -54,7 +68,7 @@ func runLabelList(c *Ctx, args []string) int {
5468 if err != nil {
5569 return c.fail(protocol.ExitFailure, "%v", err)
5670 }
57 return c.emit(labels, func(w io.Writer) {
71 return c.emitView(labels, func(w io.Writer) {
5872 tb := c.table(w, "NAME", "COLOR", "ISSUES", "MRS")
5973 for _, l := range labels {
6074 cells := []cell{cRef(l.Name), cSwatch(l.Color), cNum(l.Issues), cNum(l.MRs)}
@@ -64,6 +78,12 @@ func runLabelList(c *Ctx, args []string) int {
6478 tb.row(cells...)
6579 }
6680 tb.flush()
81 }, func() screen {
82 s := labelsScreen(labels, action{"Labels", []string{"label", "set", repo.Path(), "<label>", "--color", "rrggbb"}})
83 if len(labels) > 0 {
84 s.actions = append(s.actions, action{"Read", []string{"issue", "list", repo.Path(), "--label", labels[0].Name}})
85 }
86 return s
6787 })
6888}
6989
internal/control/milestone.go +24 −26
@@ -116,11 +116,14 @@ func runMilestoneList(c *Ctx, args []string) int {
116116 if err != nil {
117117 return c.fail(protocol.ExitFailure, "%v", err)
118118 }
119 return emitMilestones(c, ms)
119 return emitMilestones(c, ms,
120 action{"Milestones", []string{"milestone", "create", repo.Path(), "<title>"}},
121 action{"Milestones", []string{"milestone", "list", repo.Path(), "--state", "all"}},
122 )
120123}
121124
122125// emitMilestones renders a milestone list for the caller, JSON or plain.
123func emitMilestones(c *Ctx, ms []store.Milestone) int {
126func emitMilestones(c *Ctx, ms []store.Milestone, actions ...action) int {
124127 type out struct {
125128 Title string `json:"title"`
126129 Description string `json:"description,omitempty"`
@@ -134,30 +137,7 @@ func emitMilestones(c *Ctx, ms []store.Milestone) int {
134137 for _, m := range ms {
135138 ds = append(ds, out{m.Title, m.Description, m.DueDate, m.State, m.OrgID != 0, m.OpenItems, m.ClosedItems})
136139 }
137 return c.emit(ds, func(w io.Writer) {
138 if c.Term.Cols > 0 {
139 tb := c.table(w, "TITLE", "STATE", "DUE", "PROGRESS", "SCOPE")
140 for _, d := range ds {
141 due := cText("")
142 if t, ok := parseStamp(d.Due + " 00:00:00"); ok {
143 due = cText(relAge(d.Due+" 00:00:00", termNow()))
144 if d.State == "open" && t.Before(termNow()) {
145 due = cMark("overdue "+d.Due, sgrRed)
146 }
147 }
148 progress := ""
149 if total := d.Open + d.Closed; total > 0 {
150 progress = fmt.Sprintf("%d/%d closed (%d%%)", d.Closed, total, d.Closed*100/total)
151 }
152 scope := ""
153 if d.Org {
154 scope = "org"
155 }
156 tb.row(cRef(d.Title), cState(d.State), due, cText(progress), cText(scope))
157 }
158 tb.flush()
159 return
160 }
140 return c.emitView(ds, func(w io.Writer) {
161141 tb := c.table(w, "TITLE", "STATE", "DUE", "PROGRESS")
162142 for _, d := range ds {
163143 due := d.Due
@@ -171,6 +151,24 @@ func emitMilestones(c *Ctx, ms []store.Milestone) int {
171151 tb.row(cells...)
172152 }
173153 tb.flush()
154 }, func() screen {
155 rows := make([]row, len(ds))
156 for i, d := range ds {
157 lead, due := cGlyph(d.State), ""
158 if t, ok := parseStamp(d.Due + " 00:00:00"); ok {
159 due = "due " + relAge(d.Due+" 00:00:00", termNow())
160 if d.State == "open" && t.Before(termNow()) {
161 lead, due = cYou(), "overdue "+d.Due
162 }
163 }
164 progress := fmt.Sprintf("%d/%d closed", d.Closed, d.Open+d.Closed)
165 org := ""
166 if d.Org {
167 org = "org"
168 }
169 rows[i] = rowOf(cRef(d.Title), lead, cFlex(d.Description), cMeta(due, progress, org))
170 }
171 return listScreen("Milestones", rows, actions...)
174172 })
175173}
176174
internal/control/org.go +27 −2
@@ -101,12 +101,22 @@ func runOrgList(c *Ctx, args []string) int {
101101 for _, o := range orgs {
102102 ds = append(ds, out{o.Username, o.Role})
103103 }
104 return c.emit(ds, func(w io.Writer) {
104 return c.emitView(ds, func(w io.Writer) {
105105 tb := c.table(w, "ORG", "ROLE")
106106 for _, d := range ds {
107107 tb.row(cRef(d.Org), cState(d.Role))
108108 }
109109 tb.flush()
110 }, func() screen {
111 rows := make([]row, len(ds))
112 for i, d := range ds {
113 rows[i] = rowOf(cLink(d.Org, c.siteURL(d.Org)), cState(d.Role))
114 }
115 s := listScreen("Organizations", rows)
116 if len(ds) > 0 {
117 s.actions = []action{{"Read", []string{"org", "show", ds[0].Org}}}
118 }
119 return s
110120 })
111121}
112122
@@ -137,7 +147,7 @@ func runOrgShow(c *Ctx, args []string) int {
137147 Org string `json:"org"`
138148 Members []memberOut `json:"members"`
139149 }{org.Name, ms}
140 return c.emit(d, func(w io.Writer) {
150 return c.emitView(d, func(w io.Writer) {
141151 v := c.view(w)
142152 v.title(d.Org, "", "")
143153 if len(ms) > 0 {
@@ -148,6 +158,21 @@ func runOrgShow(c *Ctx, args []string) int {
148158 }
149159 tb.flush()
150160 }
161 }, func() screen {
162 members := section{title: "Members", n: len(ms)}
163 for _, m := range ms {
164 members.rows = append(members.rows, rowOf(cRef(m.User), cState(m.Role)))
165 }
166 return screen{
167 fields: []field{{"Org", []cell{cLink(d.Org, c.siteURL(d.Org))}}},
168 sections: []section{members},
169 actions: []action{
170 {"Members", []string{"org", "members", "add", d.Org, "<user>"}},
171 {"Org", []string{"org", "team", "list", d.Org}},
172 {"Org", []string{"org", "label", "list", d.Org}},
173 {"Org", []string{"org", "milestone", "list", d.Org}},
174 },
175 }
151176 })
152177}
153178
internal/control/orglabel.go +7 −2
@@ -151,12 +151,14 @@ func runOrgLabelList(c *Ctx, args []string) int {
151151 if err != nil {
152152 return c.fail(protocol.ExitFailure, "%v", err)
153153 }
154 return c.emit(labels, func(w io.Writer) {
154 return c.emitView(labels, func(w io.Writer) {
155155 tb := c.table(w, "NAME", "COLOR", "ISSUES", "MRS")
156156 for _, l := range labels {
157157 tb.row(cRef(l.Name), cSwatch(l.Color), cNum(l.Issues), cNum(l.MRs))
158158 }
159159 tb.flush()
160 }, func() screen {
161 return labelsScreen(labels, action{"Labels", []string{"org", "label", "set", org.Name, "<label>", "--color", "rrggbb"}})
160162 })
161163}
162164
@@ -231,7 +233,10 @@ func runOrgMilestoneList(c *Ctx, args []string) int {
231233 if err != nil {
232234 return c.fail(protocol.ExitFailure, "%v", err)
233235 }
234 return emitMilestones(c, ms)
236 return emitMilestones(c, ms,
237 action{"Milestones", []string{"org", "milestone", "create", org.Name, "<title>"}},
238 action{"Milestones", []string{"org", "milestone", "list", org.Name, "--state", "all"}},
239 )
235240}
236241
237242func runOrgMilestoneClose(c *Ctx, args []string) int { return setOrgMilestoneState(c, args, "closed") }
internal/control/repos/acme/site.git/HEAD added +1
@@ -0,0 +1 @@
1ref: refs/heads/main
internal/control/repos/acme/site.git/config added +7
@@ -0,0 +1,7 @@
1[core]
2 repositoryformatversion = 0
3 filemode = true
4 bare = true
5 ignorecase = true
6 precomposeunicode = true
7 hooksPath = hooks
internal/control/repos/acme/site.git/description added +1
@@ -0,0 +1 @@
1Unnamed repository; edit this file 'description' to name the repository.
internal/control/repos/acme/site.git/hooks/applypatch-msg.sample added +15
@@ -0,0 +1,15 @@
1#!/bin/sh
2#
3# An example hook script to check the commit log message taken by
4# applypatch from an e-mail message.
5#
6# The hook should exit with non-zero status after issuing an
7# appropriate message if it wants to stop the commit. The hook is
8# allowed to edit the commit message file.
9#
10# To enable this hook, rename this file to "applypatch-msg".
11
12. git-sh-setup
13commitmsg="$(git rev-parse --git-path hooks/commit-msg)"
14test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"}
15:
internal/control/repos/acme/site.git/hooks/commit-msg.sample added +74
@@ -0,0 +1,74 @@
1#!/bin/sh
2#
3# An example hook script to check the commit log message.
4# Called by "git commit" with one argument, the name of the file
5# that has the commit message. The hook should exit with non-zero
6# status after issuing an appropriate message if it wants to stop the
7# commit. The hook is allowed to edit the commit message file.
8#
9# To enable this hook, rename this file to "commit-msg".
10
11# Uncomment the below to add a Signed-off-by line to the message.
12# Doing this in a hook is a bad idea in general, but the prepare-commit-msg
13# hook is more suited to it.
14#
15# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
16# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1"
17
18# This example catches duplicate Signed-off-by lines and messages that
19# would confuse 'git am'.
20
21ret=0
22
23test "" = "$(grep '^Signed-off-by: ' "$1" |
24 sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || {
25 echo >&2 Duplicate Signed-off-by lines.
26 ret=1
27}
28
29comment_re="$(
30 {
31 git config --get-regexp "^core\.comment(char|string)\$" ||
32 echo '#'
33 } | sed -n -e '
34 ${
35 s/^[^ ]* //
36 s|[][*./\]|\\&|g
37 s/^auto$/[#;@!$%^&|:]/
38 p
39 }'
40)"
41scissors_line="^${comment_re} -\{8,\} >8 -\{8,\}\$"
42comment_line="^${comment_re}.*"
43blank_line='^[ ]*$'
44# Disallow lines starting with "diff -" or "Index: " in the body of the
45# message. Stop looking if we see a scissors line.
46line="$(sed -n -e "
47 # Skip comments and blank lines at the start of the file.
48 /${scissors_line}/q
49 /${comment_line}/d
50 /${blank_line}/d
51 # The first paragraph will become the subject header so
52 # does not need to be checked.
53 : subject
54 n
55 /${scissors_line}/q
56 /${blank_line}/!b subject
57 # Check the body of the message for problematic
58 # prefixes.
59 : body
60 n
61 /${scissors_line}/q
62 /${comment_line}/b body
63 /^diff -/{p;q;}
64 /^Index: /{p;q;}
65 b body
66 " "$1")"
67if test -n "$line"
68then
69 echo >&2 "Message contains a diff that will confuse 'git am'."
70 echo >&2 "To fix this indent the diff."
71 ret=1
72fi
73
74exit $ret
internal/control/repos/acme/site.git/hooks/fsmonitor-watchman.sample added +168
@@ -0,0 +1,168 @@
1#!/usr/bin/perl
2
3use strict;
4use warnings;
5use IPC::Open2;
6
7# An example hook script to integrate Watchman
8# (https://facebook.github.io/watchman/) with git to speed up detecting
9# new and modified files.
10#
11# The hook is passed a version (currently 2) and last update token
12# formatted as a string and outputs to stdout a new update token and
13# all files that have been modified since the update token. Paths must
14# be relative to the root of the working tree and separated by a single NUL.
15#
16# To enable this hook, rename this file to "query-watchman" and set
17# 'git config core.fsmonitor .git/hooks/query-watchman'
18#
19my ($version, $last_update_token) = @ARGV;
20
21# Uncomment for debugging
22# print STDERR "$0 $version $last_update_token\n";
23
24# Check the hook interface version
25if ($version ne 2) {
26 die "Unsupported query-fsmonitor hook version '$version'.\n" .
27 "Falling back to scanning...\n";
28}
29
30my $git_work_tree = get_working_dir();
31
32my $json_pkg;
33eval {
34 require JSON::XS;
35 $json_pkg = "JSON::XS";
36 1;
37} or do {
38 require JSON::PP;
39 $json_pkg = "JSON::PP";
40};
41
42launch_watchman();
43
44sub launch_watchman {
45 my $o = watchman_query();
46 if (is_work_tree_watched($o)) {
47 output_result($o->{clock}, @{$o->{files}});
48 }
49}
50
51sub output_result {
52 my ($clockid, @files) = @_;
53
54 # Uncomment for debugging watchman output
55 # open (my $fh, ">", ".git/watchman-output.out");
56 # binmode $fh, ":utf8";
57 # print $fh "$clockid\n@files\n";
58 # close $fh;
59
60 binmode STDOUT, ":utf8";
61 print $clockid;
62 print "\0";
63 local $, = "\0";
64 print @files;
65}
66
67sub watchman_clock {
68 my $response = qx/watchman clock "$git_work_tree"/;
69 die "Failed to get clock id on '$git_work_tree'.\n" .
70 "Falling back to scanning...\n" if $? != 0;
71
72 return $json_pkg->new->utf8->decode($response);
73}
74
75sub watchman_query {
76 my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty')
77 or die "open2() failed: $!\n" .
78 "Falling back to scanning...\n";
79
80 # In the query expression below we're asking for names of files that
81 # changed since $last_update_token but not from the .git folder.
82 #
83 # To accomplish this, we're using the "since" generator to use the
84 # recency index to select candidate nodes and "fields" to limit the
85 # output to file names only. Then we're using the "expression" term to
86 # further constrain the results.
87 my $last_update_line = "";
88 if (substr($last_update_token, 0, 1) eq "c") {
89 $last_update_token = "\"$last_update_token\"";
90 $last_update_line = qq[\n"since": $last_update_token,];
91 }
92 my $query = <<" END";
93 ["query", "$git_work_tree", {$last_update_line
94 "fields": ["name"],
95 "expression": ["not", ["dirname", ".git"]]
96 }]
97 END
98
99 # Uncomment for debugging the watchman query
100 # open (my $fh, ">", ".git/watchman-query.json");
101 # print $fh $query;
102 # close $fh;
103
104 print CHLD_IN $query;
105 close CHLD_IN;
106 my $response = do {local $/; <CHLD_OUT>};
107
108 # Uncomment for debugging the watch response
109 # open ($fh, ">", ".git/watchman-response.json");
110 # print $fh $response;
111 # close $fh;
112
113 die "Watchman: command returned no output.\n" .
114 "Falling back to scanning...\n" if $response eq "";
115 die "Watchman: command returned invalid output: $response\n" .
116 "Falling back to scanning...\n" unless $response =~ /^\{/;
117
118 return $json_pkg->new->utf8->decode($response);
119}
120
121sub is_work_tree_watched {
122 my ($output) = @_;
123 my $error = $output->{error};
124 if ($error and $error =~ m/unable to resolve root .* directory (.*) is not watched/) {
125 my $response = qx/watchman watch "$git_work_tree"/;
126 die "Failed to make watchman watch '$git_work_tree'.\n" .
127 "Falling back to scanning...\n" if $? != 0;
128 $output = $json_pkg->new->utf8->decode($response);
129 $error = $output->{error};
130 die "Watchman: $error.\n" .
131 "Falling back to scanning...\n" if $error;
132
133 # Uncomment for debugging watchman output
134 # open (my $fh, ">", ".git/watchman-output.out");
135 # close $fh;
136
137 # Watchman will always return all files on the first query so
138 # return the fast "everything is dirty" flag to git and do the
139 # Watchman query just to get it over with now so we won't pay
140 # the cost in git to look up each individual file.
141 my $o = watchman_clock();
142 $error = $o->{error};
143
144 die "Watchman: $error.\n" .
145 "Falling back to scanning...\n" if $error;
146
147 output_result($o->{clock}, ("/"));
148 return 0;
149 }
150
151 die "Watchman: $error.\n" .
152 "Falling back to scanning...\n" if $error;
153
154 return 1;
155}
156
157sub get_working_dir {
158 my $working_dir;
159 if ($^O =~ 'msys' || $^O =~ 'cygwin') {
160 $working_dir = Win32::GetCwd();
161 $working_dir =~ tr/\\/\//;
162 } else {
163 require Cwd;
164 $working_dir = Cwd::cwd();
165 }
166
167 return $working_dir;
168}
internal/control/repos/acme/site.git/hooks/post-update.sample added +8
@@ -0,0 +1,8 @@
1#!/bin/sh
2#
3# An example hook script to prepare a packed repository for use over
4# dumb transports.
5#
6# To enable this hook, rename this file to "post-update".
7
8exec git update-server-info
internal/control/repos/acme/site.git/hooks/pre-applypatch.sample added +14
@@ -0,0 +1,14 @@
1#!/bin/sh
2#
3# An example hook script to verify what is about to be committed
4# by applypatch from an e-mail message.
5#
6# The hook should exit with non-zero status after issuing an
7# appropriate message if it wants to stop the commit.
8#
9# To enable this hook, rename this file to "pre-applypatch".
10
11. git-sh-setup
12precommit="$(git rev-parse --git-path hooks/pre-commit)"
13test -x "$precommit" && exec "$precommit" ${1+"$@"}
14:
internal/control/repos/acme/site.git/hooks/pre-commit.sample added +49
@@ -0,0 +1,49 @@
1#!/bin/sh
2#
3# An example hook script to verify what is about to be committed.
4# Called by "git commit" with no arguments. The hook should
5# exit with non-zero status after issuing an appropriate message if
6# it wants to stop the commit.
7#
8# To enable this hook, rename this file to "pre-commit".
9
10if git rev-parse --verify HEAD >/dev/null 2>&1
11then
12 against=HEAD
13else
14 # Initial commit: diff against an empty tree object
15 against=$(git hash-object -t tree /dev/null)
16fi
17
18# If you want to allow non-ASCII filenames set this variable to true.
19allownonascii=$(git config --type=bool hooks.allownonascii)
20
21# Redirect output to stderr.
22exec 1>&2
23
24# Cross platform projects tend to avoid non-ASCII filenames; prevent
25# them from being added to the repository. We exploit the fact that the
26# printable range starts at the space character and ends with tilde.
27if [ "$allownonascii" != "true" ] &&
28 # Note that the use of brackets around a tr range is ok here, (it's
29 # even required, for portability to Solaris 10's /usr/bin/tr), since
30 # the square bracket bytes happen to fall in the designated range.
31 test $(git diff-index --cached --name-only --diff-filter=A -z $against |
32 LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0
33then
34 cat <<\EOF
35Error: Attempt to add a non-ASCII file name.
36
37This can cause problems if you want to work with people on other platforms.
38
39To be portable it is advisable to rename the file.
40
41If you know what you are doing you can disable this check using:
42
43 git config hooks.allownonascii true
44EOF
45 exit 1
46fi
47
48# If there are whitespace errors, print the offending file names and fail.
49exec git diff-index --check --cached $against --
internal/control/repos/acme/site.git/hooks/pre-merge-commit.sample added +13
@@ -0,0 +1,13 @@
1#!/bin/sh
2#
3# An example hook script to verify what is about to be committed.
4# Called by "git merge" with no arguments. The hook should
5# exit with non-zero status after issuing an appropriate message to
6# stderr if it wants to stop the merge commit.
7#
8# To enable this hook, rename this file to "pre-merge-commit".
9
10. git-sh-setup
11test -x "$GIT_DIR/hooks/pre-commit" &&
12 exec "$GIT_DIR/hooks/pre-commit"
13:
internal/control/repos/acme/site.git/hooks/pre-push.sample added +53
@@ -0,0 +1,53 @@
1#!/bin/sh
2
3# An example hook script to verify what is about to be pushed. Called by "git
4# push" after it has checked the remote status, but before anything has been
5# pushed. If this script exits with a non-zero status nothing will be pushed.
6#
7# This hook is called with the following parameters:
8#
9# $1 -- Name of the remote to which the push is being done
10# $2 -- URL to which the push is being done
11#
12# If pushing without using a named remote those arguments will be equal.
13#
14# Information about the commits which are being pushed is supplied as lines to
15# the standard input in the form:
16#
17# <local ref> <local oid> <remote ref> <remote oid>
18#
19# This sample shows how to prevent push of commits where the log message starts
20# with "WIP" (work in progress).
21
22remote="$1"
23url="$2"
24
25zero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')
26
27while read local_ref local_oid remote_ref remote_oid
28do
29 if test "$local_oid" = "$zero"
30 then
31 # Handle delete
32 :
33 else
34 if test "$remote_oid" = "$zero"
35 then
36 # New branch, examine all commits
37 range="$local_oid"
38 else
39 # Update to existing branch, examine new commits
40 range="$remote_oid..$local_oid"
41 fi
42
43 # Check for WIP commit
44 commit=$(git rev-list -n 1 --grep '^WIP' "$range")
45 if test -n "$commit"
46 then
47 echo >&2 "Found WIP commit in $local_ref, not pushing"
48 exit 1
49 fi
50 fi
51done
52
53exit 0
internal/control/repos/acme/site.git/hooks/pre-rebase.sample added +169
@@ -0,0 +1,169 @@
1#!/bin/sh
2#
3# Copyright (c) 2006, 2008 Junio C Hamano
4#
5# The "pre-rebase" hook is run just before "git rebase" starts doing
6# its job, and can prevent the command from running by exiting with
7# non-zero status.
8#
9# The hook is called with the following parameters:
10#
11# $1 -- the upstream the series was forked from.
12# $2 -- the branch being rebased (or empty when rebasing the current branch).
13#
14# This sample shows how to prevent topic branches that are already
15# merged to 'next' branch from getting rebased, because allowing it
16# would result in rebasing already published history.
17
18publish=next
19basebranch="$1"
20if test "$#" = 2
21then
22 topic="refs/heads/$2"
23else
24 topic=`git symbolic-ref HEAD` ||
25 exit 0 ;# we do not interrupt rebasing detached HEAD
26fi
27
28case "$topic" in
29refs/heads/??/*)
30 ;;
31*)
32 exit 0 ;# we do not interrupt others.
33 ;;
34esac
35
36# Now we are dealing with a topic branch being rebased
37# on top of master. Is it OK to rebase it?
38
39# Does the topic really exist?
40git show-ref -q "$topic" || {
41 echo >&2 "No such branch $topic"
42 exit 1
43}
44
45# Is topic fully merged to master?
46not_in_master=`git rev-list --pretty=oneline ^master "$topic"`
47if test -z "$not_in_master"
48then
49 echo >&2 "$topic is fully merged to master; better remove it."
50 exit 1 ;# we could allow it, but there is no point.
51fi
52
53# Is topic ever merged to next? If so you should not be rebasing it.
54only_next_1=`git rev-list ^master "^$topic" ${publish} | sort`
55only_next_2=`git rev-list ^master ${publish} | sort`
56if test "$only_next_1" = "$only_next_2"
57then
58 not_in_topic=`git rev-list "^$topic" master`
59 if test -z "$not_in_topic"
60 then
61 echo >&2 "$topic is already up to date with master"
62 exit 1 ;# we could allow it, but there is no point.
63 else
64 exit 0
65 fi
66else
67 not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"`
68 /usr/bin/perl -e '
69 my $topic = $ARGV[0];
70 my $msg = "* $topic has commits already merged to public branch:\n";
71 my (%not_in_next) = map {
72 /^([0-9a-f]+) /;
73 ($1 => 1);
74 } split(/\n/, $ARGV[1]);
75 for my $elem (map {
76 /^([0-9a-f]+) (.*)$/;
77 [$1 => $2];
78 } split(/\n/, $ARGV[2])) {
79 if (!exists $not_in_next{$elem->[0]}) {
80 if ($msg) {
81 print STDERR $msg;
82 undef $msg;
83 }
84 print STDERR " $elem->[1]\n";
85 }
86 }
87 ' "$topic" "$not_in_next" "$not_in_master"
88 exit 1
89fi
90
91<<\DOC_END
92
93This sample hook safeguards topic branches that have been
94published from being rewound.
95
96The workflow assumed here is:
97
98 * Once a topic branch forks from "master", "master" is never
99 merged into it again (either directly or indirectly).
100
101 * Once a topic branch is fully cooked and merged into "master",
102 it is deleted. If you need to build on top of it to correct
103 earlier mistakes, a new topic branch is created by forking at
104 the tip of the "master". This is not strictly necessary, but
105 it makes it easier to keep your history simple.
106
107 * Whenever you need to test or publish your changes to topic
108 branches, merge them into "next" branch.
109
110The script, being an example, hardcodes the publish branch name
111to be "next", but it is trivial to make it configurable via
112$GIT_DIR/config mechanism.
113
114With this workflow, you would want to know:
115
116(1) ... if a topic branch has ever been merged to "next". Young
117 topic branches can have stupid mistakes you would rather
118 clean up before publishing, and things that have not been
119 merged into other branches can be easily rebased without
120 affecting other people. But once it is published, you would
121 not want to rewind it.
122
123(2) ... if a topic branch has been fully merged to "master".
124 Then you can delete it. More importantly, you should not
125 build on top of it -- other people may already want to
126 change things related to the topic as patches against your
127 "master", so if you need further changes, it is better to
128 fork the topic (perhaps with the same name) afresh from the
129 tip of "master".
130
131Let's look at this example:
132
133 o---o---o---o---o---o---o---o---o---o "next"
134 / / / /
135 / a---a---b A / /
136 / / / /
137 / / c---c---c---c B /
138 / / / \ /
139 / / / b---b C \ /
140 / / / / \ /
141 ---o---o---o---o---o---o---o---o---o---o---o "master"
142
143
144A, B and C are topic branches.
145
146 * A has one fix since it was merged up to "next".
147
148 * B has finished. It has been fully merged up to "master" and "next",
149 and is ready to be deleted.
150
151 * C has not merged to "next" at all.
152
153We would want to allow C to be rebased, refuse A, and encourage
154B to be deleted.
155
156To compute (1):
157
158 git rev-list ^master ^topic next
159 git rev-list ^master next
160
161 if these match, topic has not merged in next at all.
162
163To compute (2):
164
165 git rev-list master..topic
166
167 if this is empty, it is fully merged to "master".
168
169DOC_END
internal/control/repos/acme/site.git/hooks/pre-receive.sample added +24
@@ -0,0 +1,24 @@
1#!/bin/sh
2#
3# An example hook script to make use of push options.
4# The example simply echoes all push options that start with 'echoback='
5# and rejects all pushes when the "reject" push option is used.
6#
7# To enable this hook, rename this file to "pre-receive".
8
9if test -n "$GIT_PUSH_OPTION_COUNT"
10then
11 i=0
12 while test "$i" -lt "$GIT_PUSH_OPTION_COUNT"
13 do
14 eval "value=\$GIT_PUSH_OPTION_$i"
15 case "$value" in
16 echoback=*)
17 echo "echo from the pre-receive-hook: ${value#*=}" >&2
18 ;;
19 reject)
20 exit 1
21 esac
22 i=$((i + 1))
23 done
24fi
internal/control/repos/acme/site.git/hooks/prepare-commit-msg.sample added +42
@@ -0,0 +1,42 @@
1#!/bin/sh
2#
3# An example hook script to prepare the commit log message.
4# Called by "git commit" with the name of the file that has the
5# commit message, followed by the description of the commit
6# message's source. The hook's purpose is to edit the commit
7# message file. If the hook fails with a non-zero status,
8# the commit is aborted.
9#
10# To enable this hook, rename this file to "prepare-commit-msg".
11
12# This hook includes three examples. The first one removes the
13# "# Please enter the commit message..." help message.
14#
15# The second includes the output of "git diff --name-status -r"
16# into the message, just before the "git status" output. It is
17# commented because it doesn't cope with --amend or with squashed
18# commits.
19#
20# The third example adds a Signed-off-by line to the message, that can
21# still be edited. This is rarely a good idea.
22
23COMMIT_MSG_FILE=$1
24COMMIT_SOURCE=$2
25SHA1=$3
26
27/usr/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE"
28
29# case "$COMMIT_SOURCE,$SHA1" in
30# ,|template,)
31# /usr/bin/perl -i.bak -pe '
32# print "\n" . `git diff --cached --name-status -r`
33# if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;;
34# *) ;;
35# esac
36
37# SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
38# git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE"
39# if test -z "$COMMIT_SOURCE"
40# then
41# /usr/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE"
42# fi
internal/control/repos/acme/site.git/hooks/push-to-checkout.sample added +78
@@ -0,0 +1,78 @@
1#!/bin/sh
2
3# An example hook script to update a checked-out tree on a git push.
4#
5# This hook is invoked by git-receive-pack(1) when it reacts to git
6# push and updates reference(s) in its repository, and when the push
7# tries to update the branch that is currently checked out and the
8# receive.denyCurrentBranch configuration variable is set to
9# updateInstead.
10#
11# By default, such a push is refused if the working tree and the index
12# of the remote repository has any difference from the currently
13# checked out commit; when both the working tree and the index match
14# the current commit, they are updated to match the newly pushed tip
15# of the branch. This hook is to be used to override the default
16# behaviour; however the code below reimplements the default behaviour
17# as a starting point for convenient modification.
18#
19# The hook receives the commit with which the tip of the current
20# branch is going to be updated:
21commit=$1
22
23# It can exit with a non-zero status to refuse the push (when it does
24# so, it must not modify the index or the working tree).
25die () {
26 echo >&2 "$*"
27 exit 1
28}
29
30# Or it can make any necessary changes to the working tree and to the
31# index to bring them to the desired state when the tip of the current
32# branch is updated to the new commit, and exit with a zero status.
33#
34# For example, the hook can simply run git read-tree -u -m HEAD "$1"
35# in order to emulate git fetch that is run in the reverse direction
36# with git push, as the two-tree form of git read-tree -u -m is
37# essentially the same as git switch or git checkout that switches
38# branches while keeping the local changes in the working tree that do
39# not interfere with the difference between the branches.
40
41# The below is a more-or-less exact translation to shell of the C code
42# for the default behaviour for git's push-to-checkout hook defined in
43# the push_to_deploy() function in builtin/receive-pack.c.
44#
45# Note that the hook will be executed from the repository directory,
46# not from the working tree, so if you want to perform operations on
47# the working tree, you will have to adapt your code accordingly, e.g.
48# by adding "cd .." or using relative paths.
49
50if ! git update-index -q --ignore-submodules --refresh
51then
52 die "Up-to-date check failed"
53fi
54
55if ! git diff-files --quiet --ignore-submodules --
56then
57 die "Working directory has unstaged changes"
58fi
59
60# This is a rough translation of:
61#
62# head_has_history() ? "HEAD" : EMPTY_TREE_SHA1_HEX
63if git cat-file -e HEAD 2>/dev/null
64then
65 head=HEAD
66else
67 head=$(git hash-object -t tree --stdin </dev/null)
68fi
69
70if ! git diff-index --quiet --cached --ignore-submodules $head --
71then
72 die "Working directory has staged changes"
73fi
74
75if ! git read-tree -u -m "$commit"
76then
77 die "Could not update working tree to new HEAD"
78fi
internal/control/repos/acme/site.git/hooks/sendemail-validate.sample added +77
@@ -0,0 +1,77 @@
1#!/bin/sh
2
3# An example hook script to validate a patch (and/or patch series) before
4# sending it via email.
5#
6# The hook should exit with non-zero status after issuing an appropriate
7# message if it wants to prevent the email(s) from being sent.
8#
9# To enable this hook, rename this file to "sendemail-validate".
10#
11# By default, it will only check that the patch(es) can be applied on top of
12# the default upstream branch without conflicts in a secondary worktree. After
13# validation (successful or not) of the last patch of a series, the worktree
14# will be deleted.
15#
16# The following config variables can be set to change the default remote and
17# remote ref that are used to apply the patches against:
18#
19# sendemail.validateRemote (default: origin)
20# sendemail.validateRemoteRef (default: HEAD)
21#
22# Replace the TODO placeholders with appropriate checks according to your
23# needs.
24
25validate_cover_letter () {
26 file="$1"
27 # TODO: Replace with appropriate checks (e.g. spell checking).
28 true
29}
30
31validate_patch () {
32 file="$1"
33 # Ensure that the patch applies without conflicts.
34 git am -3 "$file" || return
35 # TODO: Replace with appropriate checks for this patch
36 # (e.g. checkpatch.pl).
37 true
38}
39
40validate_series () {
41 # TODO: Replace with appropriate checks for the whole series
42 # (e.g. quick build, coding style checks, etc.).
43 true
44}
45
46# main -------------------------------------------------------------------------
47
48if test "$GIT_SENDEMAIL_FILE_COUNTER" = 1
49then
50 remote=$(git config --default origin --get sendemail.validateRemote) &&
51 ref=$(git config --default HEAD --get sendemail.validateRemoteRef) &&
52 worktree=$(mktemp --tmpdir -d sendemail-validate.XXXXXXX) &&
53 git worktree add -fd --checkout "$worktree" "refs/remotes/$remote/$ref" &&
54 git config --replace-all sendemail.validateWorktree "$worktree"
55else
56 worktree=$(git config --get sendemail.validateWorktree)
57fi || {
58 echo "sendemail-validate: error: failed to prepare worktree" >&2
59 exit 1
60}
61
62unset GIT_DIR GIT_WORK_TREE
63cd "$worktree" &&
64
65if grep -q "^diff --git " "$1"
66then
67 validate_patch "$1"
68else
69 validate_cover_letter "$1"
70fi &&
71
72if test "$GIT_SENDEMAIL_FILE_COUNTER" = "$GIT_SENDEMAIL_FILE_TOTAL"
73then
74 git config --unset-all sendemail.validateWorktree &&
75 trap 'git worktree remove -ff "$worktree"' EXIT &&
76 validate_series
77fi
internal/control/repos/acme/site.git/hooks/update.sample added +128
@@ -0,0 +1,128 @@
1#!/bin/sh
2#
3# An example hook script to block unannotated tags from entering.
4# Called by "git receive-pack" with arguments: refname sha1-old sha1-new
5#
6# To enable this hook, rename this file to "update".
7#
8# Config
9# ------
10# hooks.allowunannotated
11# This boolean sets whether unannotated tags will be allowed into the
12# repository. By default they won't be.
13# hooks.allowdeletetag
14# This boolean sets whether deleting tags will be allowed in the
15# repository. By default they won't be.
16# hooks.allowmodifytag
17# This boolean sets whether a tag may be modified after creation. By default
18# it won't be.
19# hooks.allowdeletebranch
20# This boolean sets whether deleting branches will be allowed in the
21# repository. By default they won't be.
22# hooks.denycreatebranch
23# This boolean sets whether remotely creating branches will be denied
24# in the repository. By default this is allowed.
25#
26
27# --- Command line
28refname="$1"
29oldrev="$2"
30newrev="$3"
31
32# --- Safety check
33if [ -z "$GIT_DIR" ]; then
34 echo "Don't run this script from the command line." >&2
35 echo " (if you want, you could supply GIT_DIR then run" >&2
36 echo " $0 <ref> <oldrev> <newrev>)" >&2
37 exit 1
38fi
39
40if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then
41 echo "usage: $0 <ref> <oldrev> <newrev>" >&2
42 exit 1
43fi
44
45# --- Config
46allowunannotated=$(git config --type=bool hooks.allowunannotated)
47allowdeletebranch=$(git config --type=bool hooks.allowdeletebranch)
48denycreatebranch=$(git config --type=bool hooks.denycreatebranch)
49allowdeletetag=$(git config --type=bool hooks.allowdeletetag)
50allowmodifytag=$(git config --type=bool hooks.allowmodifytag)
51
52# check for no description
53projectdesc=$(sed -e '1q' "$GIT_DIR/description")
54case "$projectdesc" in
55"Unnamed repository"* | "")
56 echo "*** Project description file hasn't been set" >&2
57 exit 1
58 ;;
59esac
60
61# --- Check types
62# if $newrev is 0000...0000, it's a commit to delete a ref.
63zero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')
64if [ "$newrev" = "$zero" ]; then
65 newrev_type=delete
66else
67 newrev_type=$(git cat-file -t $newrev)
68fi
69
70case "$refname","$newrev_type" in
71 refs/tags/*,commit)
72 # un-annotated tag
73 short_refname=${refname##refs/tags/}
74 if [ "$allowunannotated" != "true" ]; then
75 echo "*** The un-annotated tag, $short_refname, is not allowed in this repository" >&2
76 echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2
77 exit 1
78 fi
79 ;;
80 refs/tags/*,delete)
81 # delete tag
82 if [ "$allowdeletetag" != "true" ]; then
83 echo "*** Deleting a tag is not allowed in this repository" >&2
84 exit 1
85 fi
86 ;;
87 refs/tags/*,tag)
88 # annotated tag
89 if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1
90 then
91 echo "*** Tag '$refname' already exists." >&2
92 echo "*** Modifying a tag is not allowed in this repository." >&2
93 exit 1
94 fi
95 ;;
96 refs/heads/*,commit)
97 # branch
98 if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then
99 echo "*** Creating a branch is not allowed in this repository" >&2
100 exit 1
101 fi
102 ;;
103 refs/heads/*,delete)
104 # delete branch
105 if [ "$allowdeletebranch" != "true" ]; then
106 echo "*** Deleting a branch is not allowed in this repository" >&2
107 exit 1
108 fi
109 ;;
110 refs/remotes/*,commit)
111 # tracking branch
112 ;;
113 refs/remotes/*,delete)
114 # delete tracking branch
115 if [ "$allowdeletebranch" != "true" ]; then
116 echo "*** Deleting a tracking branch is not allowed in this repository" >&2
117 exit 1
118 fi
119 ;;
120 *)
121 # Anything else (is there anything else?)
122 echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2
123 exit 1
124 ;;
125esac
126
127# --- Finished
128exit 0
internal/control/repos/acme/site.git/info/exclude added +6
@@ -0,0 +1,6 @@
1# git ls-files --others --exclude-from=.git/info/exclude
2# Lines that start with '#' are comments.
3# For a project mostly in C, the following would be a good set of
4# exclude patterns (uncomment them if you want to use them):
5# *.[oa]
6# *~
internal/control/stage3org_test.go added +73
@@ -0,0 +1,73 @@
1package control
2
3import (
4 "strings"
5 "testing"
6
7 "gitbay.org/gitbay/internal/store"
8)
9
10// orgScreensFixture is alice with a repository carrying a label and a
11// milestone, and an org with a label, a milestone, a repository and a
12// team that holds a grant on it.
13func orgScreensFixture(t *testing.T) (*store.Store, store.Repo, store.User) {
14 t.Helper()
15 st, repo, uid := newQueueTestRepo(t)
16 u := store.User{ID: uid, Username: "alice"}
17 p := repo.Path()
18 dispatchAs(t, st, u, "", "label", "set", p, "bug", "--color", "d73a4a")
19 dispatchAs(t, st, u, "", "milestone", "create", p, "v1", "--due", "2030-01-01")
20 dispatchAs(t, st, u, "", "org", "create", "acme")
21 dispatchAs(t, st, u, "", "org", "label", "set", "acme", "triage", "--color", "0e8a16")
22 dispatchAs(t, st, u, "", "org", "milestone", "create", "acme", "Q4")
23 dispatchAs(t, st, u, "", "repo", "create", "acme/site")
24 dispatchAs(t, st, u, "", "org", "team", "create", "acme", "core")
25 dispatchAs(t, st, u, "", "org", "team", "add", "acme", "core", "alice")
26 dispatchAs(t, st, u, "", "org", "team", "grant", "acme", "core", "acme/site", "write")
27 return st, repo, u
28}
29
30func TestOrgPlainPinned(t *testing.T) {
31 st, repo, u := orgScreensFixture(t)
32 p := repo.Path()
33 for name, argv := range map[string][]string{
34 "org-list": {"org", "list"},
35 "org-show": {"org", "show", "acme"},
36 "org-members-list": {"org", "members", "list", "acme"},
37 "org-label-list": {"org", "label", "list", "acme"},
38 "label-list": {"label", "list", p},
39 "milestone-list": {"milestone", "list", p},
40 "org-milestone-list": {"org", "milestone", "list", "acme"},
41 "org-team-list": {"org", "team", "list", "acme"},
42 "org-team-show": {"org", "team", "show", "acme", "core"},
43 } {
44 pinPlain(t, name, dispatchAs(t, st, u, "", argv...))
45 }
46}
47
48func TestOrgScreens(t *testing.T) {
49 st, repo, u := orgScreensFixture(t)
50 p := repo.Path()
51 for _, tc := range []struct {
52 argv []string
53 want []string
54 }{
55 {[]string{"org", "list"}, []string{"Organizations (1)\nacme admin\n"}},
56 {[]string{"org", "show", "acme"}, []string{"Org: acme\n", "Members (1)\nalice admin\n"}},
57 {[]string{"org", "members", "list", "acme"}, []string{"Members (1)\n"}},
58 {[]string{"org", "label", "list", "acme"}, []string{"Labels (1)\n", "triage", "0 issues · 0 MRs"}},
59 {[]string{"label", "list", p}, []string{"Labels (1)\n", "bug", "0 issues · 0 MRs"}},
60 {[]string{"milestone", "list", p}, []string{"Milestones (1)\n", "v1", "0/0 closed"}},
61 {[]string{"org", "milestone", "list", "acme"}, []string{"Milestones (1)\n", "Q4"}},
62 {[]string{"org", "team", "list", "acme"}, []string{"Teams (1)\ncore\n"}},
63 {[]string{"org", "team", "show", "acme", "core"}, []string{"Team: acme/core\n", "Members: alice\n", "Grants (1)\nacme/site write\n"}},
64 } {
65 out := atTerminal(t, st, u, tc.argv...)
66 for _, w := range tc.want {
67 if !strings.Contains(out, w) {
68 t.Errorf("%v: missing %q in:\n%s", tc.argv, w, out)
69 }
70 }
71 checkLegend(t, out)
72 }
73}
internal/control/teams.go +26 −2
@@ -154,12 +154,22 @@ func runTeamList(c *Ctx, args []string) int {
154154 for _, t := range teams {
155155 names = append(names, t.Name)
156156 }
157 return c.emit(names, func(w io.Writer) {
157 return c.emitView(names, func(w io.Writer) {
158158 tb := c.table(w, "TEAM")
159159 for _, n := range names {
160160 tb.row(cRef(n))
161161 }
162162 tb.flush()
163 }, func() screen {
164 rows := make([]row, len(names))
165 for i, n := range names {
166 rows[i] = rowOf(cRef(n))
167 }
168 s := listScreen("Teams", rows)
169 if len(names) > 0 {
170 s.actions = []action{{"Read", []string{"org", "team", "show", org.Name, names[0]}}}
171 }
172 return s
163173 })
164174}
165175
@@ -188,7 +198,7 @@ func runTeamShow(c *Ctx, args []string) int {
188198 Members []string `json:"members,omitempty"`
189199 Grants []store.TeamGrant `json:"grants,omitempty"`
190200 }{team.Name, members, grants}
191 return c.emit(d, func(w io.Writer) {
201 return c.emitView(d, func(w io.Writer) {
192202 v := c.view(w)
193203 v.title(org.Name+"/"+team.Name, "", "")
194204 v.fields("members", strings.Join(members, ", "))
@@ -200,6 +210,20 @@ func runTeamShow(c *Ctx, args []string) int {
200210 }
201211 tb.flush()
202212 }
213 }, func() screen {
214 grantsSec := section{title: "Grants", n: len(grants)}
215 for _, g := range grants {
216 grantsSec.rows = append(grantsSec.rows, rowOf(cLink(g.RepoPath, c.siteURL(g.RepoPath)), cState(g.Role)))
217 }
218 s := screen{fields: []field{{"Team", []cell{cText(org.Name + "/" + team.Name)}}}, sections: []section{grantsSec}}
219 if len(members) > 0 {
220 s.fields = append(s.fields, field{"Members", []cell{cText(strings.Join(members, ", "))}})
221 }
222 s.actions = []action{
223 {"Team", []string{"org", "team", "add", org.Name, team.Name, "<user>"}},
224 {"Team", []string{"org", "team", "grant", org.Name, team.Name, "<owner/name>", "write"}},
225 }
226 return s
203227 })
204228}
205229
internal/control/testdata/plain/label-list.txt added +1
@@ -0,0 +1 @@
1bug #d73a4a 0 0
internal/control/testdata/plain/milestone-list.txt added +1
@@ -0,0 +1 @@
1v1 open due 2030-01-01 0 open, 0 closed
internal/control/testdata/plain/org-label-list.txt added +1
@@ -0,0 +1 @@
1triage #0e8a16 0 0
internal/control/testdata/plain/org-list.txt added +1
@@ -0,0 +1 @@
1acme admin
internal/control/testdata/plain/org-members-list.txt added +4
@@ -0,0 +1,4 @@
1acme
2
3members:
4alice admin
internal/control/testdata/plain/org-milestone-list.txt added +1
@@ -0,0 +1 @@
1Q4 open due - 0 open, 0 closed org
internal/control/testdata/plain/org-show.txt added +4
@@ -0,0 +1,4 @@
1acme
2
3members:
4alice admin
internal/control/testdata/plain/org-team-list.txt added +1
@@ -0,0 +1 @@
1core
internal/control/testdata/plain/org-team-show.txt added +6
@@ -0,0 +1,6 @@
1acme/core
2
3 members alice
4
5grants:
6acme/site write