aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-06-24 09:41:43 +0200
committerDanilo M. <danix@danix.xyz>2026-06-24 09:41:43 +0200
commiteeaba25642392532901cd4c95c23054e9a7ba2b3 (patch)
treefef19a8b397ccdbaf1c828c3a1c268730e36a698
parent23bf41452709a632a7f1afe2d78551da767d8d56 (diff)
downloadgitctl-eeaba25642392532901cd4c95c23054e9a7ba2b3.tar.gz
gitctl-eeaba25642392532901cd4c95c23054e9a7ba2b3.zip
feat: repo list - list all repos, public and private
New gitctl repo list / helper list-repos verb. Source of truth is the bare repos on disk (REPO_BASE/*.git), not cgitrc, so private repos (absent from cgitrc since cgit auto-discovery is off) show too. Each repo is tagged PUB/PRIV by cgitrc membership, with its section and a 60-char description from the repo's description file. Client renders an aligned table, green public / dim private on a TTY. Adds repo_sections_from() primitive; find_repo_section becomes a thin lookup over it. Self-tests cover the cgitrc parse and a temp-FS scan. Updates README, SKILL.md, bash completion (repo list action), TODO. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
-rw-r--r--README.md5
-rw-r--r--TODO.md23
-rw-r--r--completions/gitctl.bash2
-rwxr-xr-xgitctl61
-rwxr-xr-xgitctl-helper95
-rw-r--r--skills/gitctl/SKILL.md6
6 files changed, 172 insertions, 20 deletions
diff --git a/README.md b/README.md
index a965012..96fdad3 100644
--- a/README.md
+++ b/README.md
@@ -169,6 +169,7 @@ ssh git_push info # gitolite access check
gitctl sections list
gitctl sections add "Generic Projects"
gitctl repo create publisher --section SlackBuilds --desc "..."
+gitctl repo list
gitctl repo desc publisher "a new description"
gitctl repo add-remote publisher --remote-name origin
gitctl sync
@@ -185,6 +186,10 @@ git push -u origin master
- `repo create` shows the gitolite.conf diff and asks before committing. On
decline it reverts the conf edit, leaving a clean tree.
+- `repo list` lists every bare repo on disk, not just the cgit-exposed ones, so
+ private repos show too. A `VIS` column marks each `PUB` (present in cgitrc) or
+ `PRIV`; on a terminal public rows are green and private dim. The description
+ comes from each repo's `description` file, truncated to 60 chars.
- `--desc` never writes cgit `repo.desc=` directly. It writes the bare repo's
`description` file and runs sync, the single writer.
- Every operation is idempotent and safe to re-run.
diff --git a/TODO.md b/TODO.md
index 4c4a26e..6318eae 100644
--- a/TODO.md
+++ b/TODO.md
@@ -50,18 +50,21 @@ Design notes:
stanza push? Or stanza first? Decide and document - partial failure (push
fails after the move) should leave a clear, resumable state like create does.
-## 2. list repos
+## 2. list repos -- DONE
-`gitctl repo list` - show repos known to the server, ideally with their section.
+`gitctl repo list` is implemented. It lists EVERY bare repo on disk (the full
+set, public and private), not just the cgit-exposed ones, because cgit
+auto-discovery is off and private repos are not in cgitrc. Source of truth is
+`REPO_BASE/*.git`, not cgitrc; cgitrc membership only tags a repo PUB vs PRIV
+and supplies its section. Description comes from each repo's `description` file
+(truncated 60). Client renders a table, green public / dim private on a TTY.
+Helper verb `list-repos`; primitive `list_repos()` + `repo_sections_from()`
+(the latter dedupes the old `find_repo_section`). Self-tests cover both the
+cgitrc parse and a temp-FS scan.
-- New helper verb `list-repos`: parse /etc/cgitrc for `repo.url=` lines and the
- preceding `section=` (reuse `find_repo_section` / the section-tracking loop).
- Print `<section>\t<url>` or similar.
-- Cheap, read-only, no confirmation. Good first item - unblocks better bash
- completion (complete repo names for `desc` / `delete` / `add-remote`) and
- gives `delete` something to validate against.
-- Alternative source: gitolite (`ssh git_push info` lists repos) but cgitrc is
- the helper's own domain and already parsed; prefer it.
+Still open from the original plan: extend bash completion to complete repo
+NAMES (for `desc` / `delete` / `add-remote`) now that a list verb exists. The
+`repo list` action itself is already in completion.
## 3. `-y` / `--yes` flag on `repo create`
diff --git a/completions/gitctl.bash b/completions/gitctl.bash
index 0b680dd..6fead65 100644
--- a/completions/gitctl.bash
+++ b/completions/gitctl.bash
@@ -31,7 +31,7 @@ _gitctl() {
2)
case $group in
sections) COMPREPLY=($(compgen -W "list add" -- "$cur")) ;;
- repo) COMPREPLY=($(compgen -W "create desc add-remote" -- "$cur")) ;;
+ repo) COMPREPLY=($(compgen -W "create list desc add-remote" -- "$cur")) ;;
esac
return ;;
esac
diff --git a/gitctl b/gitctl
index 88ba37a..11d9449 100755
--- a/gitctl
+++ b/gitctl
@@ -50,12 +50,13 @@ def helper_command(args):
return " ".join(shlex.quote(a) for a in args)
-def call_helper(cfg, args, check=True):
+def call_helper(cfg, args, check=True, quiet=False):
"""Run a helper verb. Returns CompletedProcess. ssh sends the verb string;
- the restricted key forces gitctl-helper, which reads SSH_ORIGINAL_COMMAND."""
+ the restricted key forces gitctl-helper, which reads SSH_ORIGINAL_COMMAND.
+ quiet suppresses the stdout passthrough so the caller can format it."""
cmd = ["ssh", cfg["helper_ssh_alias"], helper_command(args)]
r = subprocess.run(cmd, text=True, capture_output=True)
- if r.stdout:
+ if r.stdout and not quiet:
sys.stdout.write(r.stdout)
if r.stderr:
sys.stderr.write(r.stderr)
@@ -104,6 +105,20 @@ def _self_test():
# conf without trailing newline: separator inserted so stanza lands clean
assert add_stanza("repo a\n RW+ = youruser", "x", "youruser") == \
"repo a\n RW+ = youruser\n\nrepo x\n RW+ = youruser\n"
+ # repo table: columns padded to the widest cell (header included), desc not
+ # padded, private trailing-blank cells stripped. Color off => no escapes.
+ t = format_repo_table([
+ ("cad", "PUB", "Generic", "CAD work"),
+ ("secret", "PRIV", "", ""),
+ ])
+ assert t == (
+ "NAME VIS SECTION DESCRIPTION\n"
+ "cad PUB Generic CAD work\n"
+ "secret PRIV"
+ ), repr(t)
+ # color on: public row green, private dim, header uncolored.
+ tc = format_repo_table([("cad", "PUB", "G", "d"), ("s", "PRIV", "", "")], color=True)
+ assert GREEN in tc and DIM in tc and tc.startswith("NAME")
print("self-test OK")
@@ -122,6 +137,38 @@ def cmd_add_remote(cfg, name, remote_name):
return 0
+GREEN, DIM, RESET = "\033[32m", "\033[2m", "\033[0m"
+
+
+def format_repo_table(rows, color=False):
+ """rows: list of (name, vis, section, desc) from the list-repos helper.
+ Returns the rendered table as a string. Public rows green, private dim
+ when color is on. Pure function: no I/O, so the self-test can pin it."""
+ header = ("NAME", "VIS", "SECTION", "DESCRIPTION")
+ table = [header] + rows
+ widths = [max(len(r[i]) for r in table) for i in range(3)] # desc not padded
+ out = []
+ for i, r in enumerate(table):
+ line = " ".join([
+ r[0].ljust(widths[0]), r[1].ljust(widths[1]),
+ r[2].ljust(widths[2]), r[3],
+ ]).rstrip()
+ if color and i > 0:
+ line = (GREEN if r[1] == "PUB" else DIM) + line + RESET
+ out.append(line)
+ return "\n".join(out)
+
+
+def cmd_list(cfg):
+ r = call_helper(cfg, ["list-repos"], quiet=True)
+ rows = [tuple(l.split("\t")) for l in r.stdout.splitlines() if l]
+ if not rows:
+ print("no repos")
+ return 0
+ print(format_repo_table(rows, color=sys.stdout.isatty()))
+ return 0
+
+
def _confirm(prompt):
return input(prompt + " [y/N] ").strip().lower() in ("y", "yes")
@@ -223,6 +270,12 @@ def build_parser():
rd.add_argument("name", help="repo name")
rd.add_argument("description", help="new description text (quote if it has spaces)")
+ rep.add_parser(
+ "list", help="list every repo on the server, public and private",
+ description="List all bare repos on the server (the full set, not just the "
+ "cgit-exposed ones). A VIS column marks each public (in cgit) or "
+ "private; on a terminal public rows are green, private dim.")
+
rr = rep.add_parser(
"add-remote", help="add a git remote for the repo in the current directory",
description="Add a git remote pointing at the repo on the server, in the "
@@ -246,6 +299,8 @@ def main():
call_helper(cfg, ["sync"]); return 0
if a.group == "repo" and a.action == "desc":
call_helper(cfg, ["set-desc", "--url", a.name, "--desc", a.description]); return 0
+ if a.group == "repo" and a.action == "list":
+ return cmd_list(cfg)
if a.group == "repo" and a.action == "add-remote":
return cmd_add_remote(cfg, a.name, a.remote_name)
if a.group == "repo" and a.action == "create":
diff --git a/gitctl-helper b/gitctl-helper
index 8cdf460..6c6dbc3 100755
--- a/gitctl-helper
+++ b/gitctl-helper
@@ -65,8 +65,12 @@ def list_sections_from(lines):
return out
-def find_repo_section(lines, url):
- """Return the section a repo.url= belongs to, or None if absent."""
+def repo_sections_from(lines):
+ """Map every repo.url= in cgitrc to the section= it falls under (file order).
+
+ cgitrc lists only the repos exposed to the web (cgit auto-discovery is off),
+ so membership here is exactly what makes a repo public."""
+ out = {}
current = None
for line in lines:
s = SECTION_RE.match(line.rstrip("\n"))
@@ -74,9 +78,46 @@ def find_repo_section(lines, url):
current = s.group(1)
continue
u = URL_LINE_RE.match(line.rstrip("\n"))
- if u and u.group(1) == url:
- return current
- return None
+ if u:
+ out[u.group(1)] = current
+ return out
+
+
+def find_repo_section(lines, url):
+ """Return the section a repo.url= belongs to, or None if absent."""
+ return repo_sections_from(lines).get(url)
+
+
+DESC_MAX = 60
+
+
+def read_description(url):
+ """First line of the bare repo's description file, truncated. '' if missing
+ or the gitolite placeholder."""
+ try:
+ with open(os.path.join(repo_path_for(url), "description")) as f:
+ d = f.readline().strip()
+ except OSError:
+ return ""
+ if d.startswith("Unnamed repository"):
+ return ""
+ return d[:DESC_MAX - 1] + "…" if len(d) > DESC_MAX else d
+
+
+def list_repos(repo_base, cgitrc_lines):
+ """Every bare repo under repo_base (the full set, public and private), each
+ tagged public/private and with its section + description. Returns a list of
+ (name, public_bool, section, desc) sorted by name."""
+ public = repo_sections_from(cgitrc_lines)
+ out = []
+ for entry in sorted(os.listdir(repo_base)):
+ if not entry.endswith(".git"):
+ continue
+ if not os.path.isdir(os.path.join(repo_base, entry)):
+ continue
+ name = entry[:-4]
+ out.append((name, name in public, public.get(name) or "", read_description(name)))
+ return out
def repo_path_for(url):
@@ -212,6 +253,37 @@ def _self_test():
assert find_repo_section(fixture, "cad-projects") == "Generic Projects"
assert find_repo_section(fixture, "my-slackbuilds") == "SlackBuilds"
assert find_repo_section(fixture, "nope") is None
+ assert repo_sections_from(fixture) == {
+ "cad-projects": "Generic Projects",
+ "my-slackbuilds": "SlackBuilds",
+ }
+
+ # list_repos: scans a fake REPO_BASE, tags public/private by cgitrc and
+ # reads each description file. cad-projects is in the fixture (public),
+ # secret-stuff is not (private).
+ import tempfile
+ rb = tempfile.mkdtemp()
+ for n, d in [("cad-projects", "CAD work"), ("secret-stuff", "")]:
+ os.mkdir(os.path.join(rb, n + ".git"))
+ if d:
+ open(os.path.join(rb, n + ".git", "description"), "w").write(d + "\n")
+ open(os.path.join(rb, "not-a-repo"), "w").write("ignore me\n") # non-.git skipped
+ # repo_path_for/read_description read from the real REPO_BASE; point it here.
+ global REPO_BASE
+ saved_base, REPO_BASE = REPO_BASE, rb
+ rows = list_repos(rb, fixture)
+ REPO_BASE = saved_base
+ assert rows == [
+ ("cad-projects", True, "Generic Projects", "CAD work"),
+ ("secret-stuff", False, "", ""),
+ ], rows
+ # truncation: a long description is cut to DESC_MAX chars (ellipsis included).
+ os.mkdir(os.path.join(rb, "wordy.git"))
+ open(os.path.join(rb, "wordy.git", "description"), "w").write("x" * 100 + "\n")
+ REPO_BASE = rb
+ long = read_description("wordy")
+ REPO_BASE = saved_base
+ assert len(long) == DESC_MAX and long.endswith("…"), long
block = build_repo_block("publisher", "by Your Name")
assert block == [
@@ -390,6 +462,13 @@ def main():
"push until gitolite has built the bare repo.")
re_.add_argument("--url", required=True, help="repo name to test for (as <url>.git)")
+ sub.add_parser("list-repos",
+ help="list every bare repo, tagged public/private",
+ description="Scan the repository base for all bare repos (the full "
+ "set, not just the cgit-exposed ones), tag each public or "
+ "private by cgitrc membership, and print one "
+ "name<TAB>PUB|PRIV<TAB>section<TAB>description line each.")
+
a = p.parse_args(argv)
if a.verb == "list-sections":
@@ -413,6 +492,12 @@ def main():
if a.verb == "repo-exists":
validate_url(a.url)
return 0 if os.path.isdir(repo_path_for(a.url)) else 1
+ if a.verb == "list-repos":
+ with open(CGITRC) as f:
+ cgit = f.readlines()
+ for name, pub, section, desc in list_repos(REPO_BASE, cgit):
+ print(f"{name}\t{'PUB' if pub else 'PRIV'}\t{section}\t{desc}")
+ return 0
return 1
diff --git a/skills/gitctl/SKILL.md b/skills/gitctl/SKILL.md
index b701153..9d6ac17 100644
--- a/skills/gitctl/SKILL.md
+++ b/skills/gitctl/SKILL.md
@@ -17,6 +17,7 @@ setup" and "Client install") rather than guessing.
## When to use
- User wants a NEW repo on the server -> `gitctl repo create`
+- List every repo (public AND private) -> `gitctl repo list`
- List/add cgit sections (categories) -> `gitctl sections list|add`
- Set a repo's cgit description -> `gitctl repo desc`
- Add the server as a git remote in the CWD repo -> `gitctl repo add-remote`
@@ -62,7 +63,10 @@ git push -u origin master
## Discovering current state
```
+gitctl repo list # every bare repo, public (in cgit) and private
gitctl sections list # existing cgit sections (categories)
```
-There is no repo-list verb on the client; a section is required for create, so
+`repo list` scans the bare repos on disk (the full set, not just cgit-exposed
+ones) and marks each public/private with a VIS column (green/dim on a TTY); use
+it to confirm a name exists or pick one. A section is required for create, so
list sections first if unsure which category to use.