From eeaba25642392532901cd4c95c23054e9a7ba2b3 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Wed, 24 Jun 2026 09:41:43 +0200 Subject: 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 --- README.md | 5 +++ TODO.md | 23 ++++++------ completions/gitctl.bash | 2 +- gitctl | 61 +++++++++++++++++++++++++++++-- gitctl-helper | 95 ++++++++++++++++++++++++++++++++++++++++++++++--- skills/gitctl/SKILL.md | 6 +++- 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 `
\t` 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 .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 " + "namePUB|PRIVsectiondescription 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. -- cgit v1.2.3