From 939232e72e562c5d783ecef879e4f6efbe46c9e9 Mon Sep 17 00:00:00 2001 From: Schmidt Peter <117939077+jokerz5575@users.noreply.github.com> Date: Sat, 23 May 2026 21:03:54 +0200 Subject: [PATCH] Added features Added: Project Overview TUI Build Cache / Dirty Detection Post-Build Hooks gbuild status --- Makefile | 5 +- README.md | 168 +++--- bin/gbuild | Bin 41592 -> 59440 bytes bin/gconfig | Bin 17224 -> 21464 bytes changelog.txt | 1352 +++++++++++++++++++++++++++++++++++++++++++++ include/cache.h | 35 ++ include/config.h | 28 + include/git_ops.h | 16 + include/hooks.h | 18 + include/index.h | 82 +++ include/tui.h | 13 + src/cache.c | 65 +++ src/config.c | 96 +++- src/gbuild.c | 395 ++++++++++--- src/git_ops.c | 118 +++- src/hooks.c | 82 +++ src/index.c | 277 ++++++++++ src/tui.c | 290 ++++++++-- 18 files changed, 2768 insertions(+), 272 deletions(-) create mode 100644 changelog.txt create mode 100644 include/cache.h create mode 100644 include/hooks.h create mode 100644 include/index.h create mode 100644 src/cache.c create mode 100644 src/hooks.c create mode 100644 src/index.c diff --git a/Makefile b/Makefile index 2c21f21..a92d3ec 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ CC = gcc CFLAGS = -Wall -Wextra -O2 -Iinclude -LDLIBS_GBUILD = -lncurses +LDLIBS_GBUILD = -lncurses -lpthread LDLIBS_GCONFIG = # ---- package metadata -------------------------------------- @@ -29,7 +29,10 @@ COMMON_SRCS = src/config.c GBUILD_SRCS = \ src/gbuild.c \ + src/cache.c \ + src/hooks.c \ src/git_ops.c \ + src/index.c \ src/logger.c \ src/make_ops.c \ src/tui.c \ diff --git a/README.md b/README.md index 0a8ef78..710f1f2 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,75 @@ -# gbuild +# gbuild -A C rewrite of the original [gbuild](https://spdlab.hu/gbuild) bash tool. -Clone, pull, pick a target, build — all from one command. +gbuild started as a bash script to stop me from typing the same git-clone, +cd, make sequence over and over. It's since been rewritten in C and grown a +few useful features — but the idea is still the same: one command to get +the latest code and build it. --- -## Features +## What it does -- **Clone or pull** — automatically clones a fresh repo on first run; pulls on subsequent runs -- **Interactive target picker** — reads your `Makefile` and presents an ncurses TUI to select the build target; pass `--target` to skip it -- **Timestamped log files** — every run writes a log to `~/.local/log/gbuild/` named `_.log` -- **Log browser** — built-in two-pane TUI: file list on the left, live preview on the right; open in `less`, delete, or quit -- **Configurable** — reads `~/.gconfig`; override with `--url` and `--user` at runtime -- **`gconfig` companion** — manages `~/.gconfig` with `init`, `show`, and `help` commands +Run `gbuild` with no arguments and you get a project overview — every repo +you've built before, when you last built it, whether it passed or failed, +and the HEAD hash it was on. Pick one with J/K and hit Enter. + +Run `gbuild myproject` to go straight to a specific project. If it hasn't +been cloned yet, gbuild clones it first. If it has, it pulls. Then it reads +your Makefile, shows you the targets, and you pick one. Pass `--target` if +you already know what you want and don't need the picker. + +Every build gets logged to `~/.local/log/gbuild/`. Run `gbuild --logs` to +browse them — file list on the left, preview on the right, open anything in +`less` if you need the full output. + +Configuration lives in `~/.gconfig`. Run `gconfig init` to create one, then +edit it with your git server URL and credentials. Token and password auth are +both supported. --- ## Dependencies -| Library | Purpose | -|----------|------------------------------| -| ncurses | TUI for picker & log browser | -| git | Clone / pull | -| make | Build projects | -| less | Open logs from browser | +You'll need `gcc`, `make`, `git`, `less`, and the ncurses development headers. +Most systems have everything except the ncurses headers — the package is +usually called `libncurses-dev`, `ncurses-devel`, or just `ncurses` depending +on your distribution. --- -## Build - -### Linux +## Building ```sh -# Install these packages with your package manager -libncurses-dev make - -# Clone this repository -git clone https://github.com/jokerz/gbuild.git - -# Build both binaries into bin/ make - -# Optionally install system-wide sudo make install ``` +That puts `gbuild` and `gconfig` in `/usr/local/bin`. If you want them +somewhere else, `sudo make install PREFIX=/your/path`. + +There's also `make release` which produces a source tarball, a stripped +binary tarball, a `.deb`, and an `.rpm` all in one go under `dist/`. + --- -## Quick setup +## Setup ```sh -# 1. Initialise ~/.gconfig with defaults -gconfig init - -# 2. Fill in your values -$EDITOR ~/.gconfig - -# 3. Verify -gconfig show - -# 4. Build a project -gbuild myproject +gconfig init # creates ~/.gconfig with defaults +$EDITOR ~/.gconfig # fill in your server URL and credentials +gconfig show # check it looks right +gbuild # open the overview and pick something to build ``` -### `~/.gconfig` format +Your `~/.gconfig` looks like this: ```ini -# ~/.gconfig — managed by gconfig - [git] GIT_URL = http://localhost:3000 -GIT_USER = Username +GIT_USER = myuser [auth] -# Use token-based OR password-based auth; leave the other blank +# token or password, leave the other blank GIT_TOKEN = GIT_PASSWORD = @@ -89,70 +85,50 @@ LOG_DIR = ~/.local/log/gbuild --- ## Usage - ``` -gbuild [options] -gbuild --logs - -Options: - --url Override Git base URL (default: ~/.gconfig) - --user Override Git username (default: ~/.gconfig) - --target Run target directly, skip interactive picker - --logs Open the interactive log browser - -h, --help Print this help and exit +gbuild open the project overview +gbuild clone/pull and build +gbuild --target

skip the picker, run target t +gbuild --logs open the log browser +gbuild --no-tui print usage (useful in scripts) +gbuild --url --user [username] [projectname] one-off config override ``` - -### Examples - -```sh -# Build with defaults -gbuild myproject - -# Skip the target picker -gbuild --target clean myproject - -# Different server and user for this run -gbuild --url http://10.0.0.5:3000 --user alice myproject - -# Browse past build logs -gbuild --logs -``` - --- -## `gconfig` commands - -| Command | Description | -|---------------------|-------------------------------------------------------------| -| `gconfig init` | Create `~/.gconfig` with defaults (skips if already exists) | -| `gconfig init --force` | Overwrite; backs up old file as `~/.gconfig.bak.` | -| `gconfig show` | Print current config (auth fields masked) | -| `gconfig help` | Print usage | - +## gconfig +``` +gconfig init create ~/.gconfig (won't overwrite) +gconfig init --force overwrite, backs up the old file first +gconfig show print config, masks auth fields +gconfig help +``` --- ## Project layout - ``` gbuild/ ├── Makefile ├── README.md ├── include/ -│ ├── config.h — GConfig struct, INI load/save/init/show -│ ├── git_ops.h — clone / pull -│ ├── logger.h — timestamped coloured logging -│ ├── make_ops.h — Makefile target parser + build runner -│ └── tui.h — ncurses target picker + log browser +│ ├── config.h ~/.gconfig read/write +│ ├── git_ops.h clone, pull, HEAD hash +│ ├── index.h ~/.gbuild_index — persistent build history +│ ├── logger.h terminal output + log files +│ ├── make_ops.h Makefile parser and build runner +│ └── tui.h all three ncurses screens +├── pkg/ +│ ├── deb/ .deb package templates +│ └── rpm/ .rpm spec template └── src/ - ├── config.c - ├── gbuild.c — gbuild main() - ├── gconfig.c — gconfig main() - ├── git_ops.c - ├── logger.c - ├── make_ops.c - └── tui.c +├── gbuild.c +├── gconfig.c +├── config.c +├── git_ops.c +├── index.c +├── logger.c +├── make_ops.c +└── tui.c ``` - --- -MIT License — maintained by jokerz / spdlab.hu +MIT Copyright (c) Schmidt Peter Daniel 2026 diff --git a/bin/gbuild b/bin/gbuild index 6c3664f9f5284b0f0a1f4c17c7611fee646d809e..642f3987a21c04cf8cfb3235bfcc384338f1cff2 100644 GIT binary patch literal 59440 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 literal 41592 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 diff --git a/bin/gconfig b/bin/gconfig index 2b1ee61791965b2729a817569f8edbcaa1b069f6..f77a9cf0229a132be7b5631184cba39d5fd26f76 100644 GIT binary patch literal 21464 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 Clcrz* diff --git a/changelog.txt b/changelog.txt new file mode 100644 index 0000000..2e5cb3f --- /dev/null +++ b/changelog.txt @@ -0,0 +1,1352 @@ +diff --git a/src/logger.c b/src/logger.c +index de2eead..997a975 100644 +--- a/src/logger.c ++++ b/src/logger.c +@@ -71,9 +71,9 @@ typedef enum { LVL_INFO, LVL_OK, LVL_WARN, LVL_ERROR } LogLevel; + static const char *level_tag(LogLevel lv) + { + switch (lv) { +- case LVL_INFO: return "[INFO ]"; +- case LVL_OK: return "[OK ]"; +- case LVL_WARN: return "[WARN ]"; ++ case LVL_INFO: return "[INFO]"; ++ case LVL_OK: return "[OK]"; ++ case LVL_WARN: return "[WARN]"; + case LVL_ERROR: return "[ERROR]"; + } + return "[ ]"; +diff --git a/bin/gbuild b/bin/gbuild +deleted file mode 100755 +index dc9ad62..0000000 +Binary files a/bin/gbuild and /dev/null differ +diff --git a/bin/gconfig b/bin/gconfig +deleted file mode 100755 +index 2b1ee61..0000000 +Binary files a/bin/gconfig and /dev/null differ +diff --git a/dist/gbuild-1.0.0-1.x86_64.rpm b/dist/gbuild-1.0.0-1.x86_64.rpm +deleted file mode 100644 +index 2ac5ced..0000000 +Binary files a/dist/gbuild-1.0.0-1.x86_64.rpm and /dev/null differ +diff --git a/dist/gbuild-1.0.0-x86_64-bin.tar.gz b/dist/gbuild-1.0.0-x86_64-bin.tar.gz +deleted file mode 100644 +index 8e410d7..0000000 +Binary files a/dist/gbuild-1.0.0-x86_64-bin.tar.gz and /dev/null differ +diff --git a/dist/gbuild-1.0.0.tar.gz b/dist/gbuild-1.0.0.tar.gz +deleted file mode 100644 +index 4fea580..0000000 +Binary files a/dist/gbuild-1.0.0.tar.gz and /dev/null differ +diff --git a/dist/gbuild-debuginfo-1.0.0-1.x86_64.rpm b/dist/gbuild-debuginfo-1.0.0-1.x86_64.rpm +deleted file mode 100644 +index c0bcf34..0000000 +Binary files a/dist/gbuild-debuginfo-1.0.0-1.x86_64.rpm and /dev/null differ +diff --git a/dist/gbuild_1.0.0_amd64.deb b/dist/gbuild_1.0.0_amd64.deb +deleted file mode 100644 +index 58b5e08..0000000 +Binary files a/dist/gbuild_1.0.0_amd64.deb and /dev/null differ +diff --git a/bin/gbuild b/bin/gbuild +deleted file mode 100755 +index dc9ad62..0000000 +Binary files a/bin/gbuild and /dev/null differ +diff --git a/bin/gconfig b/bin/gconfig +deleted file mode 100755 +index 2b1ee61..0000000 +Binary files a/bin/gconfig and /dev/null differ +diff --git a/changelog.txt b/changelog.txt +index 2367a82..374e581 100644 +--- a/changelog.txt ++++ b/changelog.txt +@@ -15,3 +15,31 @@ index de2eead..997a975 100644 + case LVL_ERROR: return "[ERROR]"; + } + return "[ ]"; ++diff --git a/bin/gbuild b/bin/gbuild ++deleted file mode 100755 ++index dc9ad62..0000000 ++Binary files a/bin/gbuild and /dev/null differ ++diff --git a/bin/gconfig b/bin/gconfig ++deleted file mode 100755 ++index 2b1ee61..0000000 ++Binary files a/bin/gconfig and /dev/null differ ++diff --git a/dist/gbuild-1.0.0-1.x86_64.rpm b/dist/gbuild-1.0.0-1.x86_64.rpm ++deleted file mode 100644 ++index 2ac5ced..0000000 ++Binary files a/dist/gbuild-1.0.0-1.x86_64.rpm and /dev/null differ ++diff --git a/dist/gbuild-1.0.0-x86_64-bin.tar.gz b/dist/gbuild-1.0.0-x86_64-bin.tar.gz ++deleted file mode 100644 ++index 8e410d7..0000000 ++Binary files a/dist/gbuild-1.0.0-x86_64-bin.tar.gz and /dev/null differ ++diff --git a/dist/gbuild-1.0.0.tar.gz b/dist/gbuild-1.0.0.tar.gz ++deleted file mode 100644 ++index 4fea580..0000000 ++Binary files a/dist/gbuild-1.0.0.tar.gz and /dev/null differ ++diff --git a/dist/gbuild-debuginfo-1.0.0-1.x86_64.rpm b/dist/gbuild-debuginfo-1.0.0-1.x86_64.rpm ++deleted file mode 100644 ++index c0bcf34..0000000 ++Binary files a/dist/gbuild-debuginfo-1.0.0-1.x86_64.rpm and /dev/null differ ++diff --git a/dist/gbuild_1.0.0_amd64.deb b/dist/gbuild_1.0.0_amd64.deb ++deleted file mode 100644 ++index 58b5e08..0000000 ++Binary files a/dist/gbuild_1.0.0_amd64.deb and /dev/null differ +diff --git a/dist/gbuild-1.0.0-1.x86_64.rpm b/dist/gbuild-1.0.0-1.x86_64.rpm +deleted file mode 100644 +index 2ac5ced..0000000 +Binary files a/dist/gbuild-1.0.0-1.x86_64.rpm and /dev/null differ +diff --git a/dist/gbuild-1.0.0-x86_64-bin.tar.gz b/dist/gbuild-1.0.0-x86_64-bin.tar.gz +deleted file mode 100644 +index 8e410d7..0000000 +Binary files a/dist/gbuild-1.0.0-x86_64-bin.tar.gz and /dev/null differ +diff --git a/dist/gbuild-1.0.0.tar.gz b/dist/gbuild-1.0.0.tar.gz +deleted file mode 100644 +index 4fea580..0000000 +Binary files a/dist/gbuild-1.0.0.tar.gz and /dev/null differ +diff --git a/dist/gbuild-debuginfo-1.0.0-1.x86_64.rpm b/dist/gbuild-debuginfo-1.0.0-1.x86_64.rpm +deleted file mode 100644 +index c0bcf34..0000000 +Binary files a/dist/gbuild-debuginfo-1.0.0-1.x86_64.rpm and /dev/null differ +diff --git a/dist/gbuild_1.0.0_amd64.deb b/dist/gbuild_1.0.0_amd64.deb +deleted file mode 100644 +index 58b5e08..0000000 +Binary files a/dist/gbuild_1.0.0_amd64.deb and /dev/null differ +diff --git a/src/tui.c b/src/tui.c +index b07ea65..a2465df 100644 +--- a/src/tui.c ++++ b/src/tui.c +@@ -247,10 +247,10 @@ void tui_log_browser(const char *log_dir) + if (has_colors()) { + start_color(); + use_default_colors(); +- init_pair(1, COLOR_BLACK, COLOR_CYAN); /* selected row */ +- init_pair(2, COLOR_CYAN, -1); /* border */ ++ init_pair(1, COLOR_BLACK, COLOR_WHITE); /* selected row */ ++ init_pair(2, COLOR_WHITE, -1); /* border */ + init_pair(3, COLOR_WHITE, -1); /* normal row */ +- init_pair(4, COLOR_YELLOW,-1); /* preview text */ ++ init_pair(4, COLOR_WHITE,-1); /* preview text */ + init_pair(5, COLOR_RED, -1); /* status/warn */ + } + +@@ -282,7 +282,7 @@ void tui_log_browser(const char *log_dir) + /* ---- hint bar ---- */ + if (has_colors()) attron(COLOR_PAIR(2)); + mvprintw(rows - 2, 0, +- " \u2191\u2193 navigate Enter open in less d delete q quit " ++ " J - DOWN K - UP navigate Enter - open in less d - delete q - quit " + " "); + if (has_colors()) attroff(COLOR_PAIR(2)); + +diff --git a/src/tui.c b/src/tui.c +index a2465df..103babc 100644 +--- a/src/tui.c ++++ b/src/tui.c +@@ -282,7 +282,7 @@ void tui_log_browser(const char *log_dir) + /* ---- hint bar ---- */ + if (has_colors()) attron(COLOR_PAIR(2)); + mvprintw(rows - 2, 0, +- " J - DOWN K - UP navigate Enter - open in less d - delete q - quit " ++ " K - UP J - DOWN to navigate Enter - open in less d - delete q - quit (Press J to start) " + " "); + if (has_colors()) attroff(COLOR_PAIR(2)); + +@@ -389,10 +389,10 @@ void tui_log_browser(const char *log_dir) + if (has_colors()) { + start_color(); + use_default_colors(); +- init_pair(1, COLOR_BLACK, COLOR_CYAN); +- init_pair(2, COLOR_CYAN, -1); ++ init_pair(1, COLOR_BLACK, COLOR_WHITE); ++ init_pair(2, COLOR_WHITE, -1); + init_pair(3, COLOR_WHITE, -1); +- init_pair(4, COLOR_YELLOW,-1); ++ init_pair(4, COLOR_WHITE,-1); + init_pair(5, COLOR_RED, -1); + } + clear(); +diff --git a/bin/gbuild b/bin/gbuild +index 3585f82..09371a5 100755 +Binary files a/bin/gbuild and b/bin/gbuild differ +diff --git a/dist/gbuild-1.0.0-1.x86_64.rpm b/dist/gbuild-1.0.0-1.x86_64.rpm +deleted file mode 100644 +index 2343555..0000000 +Binary files a/dist/gbuild-1.0.0-1.x86_64.rpm and /dev/null differ +diff --git a/dist/gbuild-1.0.0-x86_64-bin.tar.gz b/dist/gbuild-1.0.0-x86_64-bin.tar.gz +deleted file mode 100644 +index cdbc281..0000000 +Binary files a/dist/gbuild-1.0.0-x86_64-bin.tar.gz and /dev/null differ +diff --git a/dist/gbuild-1.0.0.tar.gz b/dist/gbuild-1.0.0.tar.gz +deleted file mode 100644 +index c8fea5c..0000000 +Binary files a/dist/gbuild-1.0.0.tar.gz and /dev/null differ +diff --git a/dist/gbuild-debuginfo-1.0.0-1.x86_64.rpm b/dist/gbuild-debuginfo-1.0.0-1.x86_64.rpm +deleted file mode 100644 +index 04797e8..0000000 +Binary files a/dist/gbuild-debuginfo-1.0.0-1.x86_64.rpm and /dev/null differ +diff --git a/dist/gbuild_1.0.0_amd64.deb b/dist/gbuild_1.0.0_amd64.deb +deleted file mode 100644 +index a0b5475..0000000 +Binary files a/dist/gbuild_1.0.0_amd64.deb and /dev/null differ +diff --git a/src/tui.c b/src/tui.c +index 103babc..1442086 100644 +--- a/src/tui.c ++++ b/src/tui.c +@@ -189,7 +189,7 @@ static int load_log_entries(const char *log_dir, + * Returns the number of lines read. + * Caller owns the memory and should free each pointer. + */ +-#define PREVIEW_MAX 128 ++#define PREVIEW_MAX 200 + + static int read_tail(const char *path, char **lines, int max_lines) + { +@@ -279,11 +279,6 @@ void tui_log_browser(const char *log_dir) + attroff(A_BOLD); + clrtoeol(); + +- /* ---- hint bar ---- */ +- if (has_colors()) attron(COLOR_PAIR(2)); +- mvprintw(rows - 2, 0, +- " K - UP J - DOWN to navigate Enter - open in less d - delete q - quit (Press J to start) " +- " "); + if (has_colors()) attroff(COLOR_PAIR(2)); + + /* ---- vertical divider ---- */ +@@ -351,7 +346,13 @@ void tui_log_browser(const char *log_dir) + mvwprintw(wright, 2, 2, "(no log selected)"); + } + wrefresh(wright); +- refresh(); ++ ++ /* ---- hint bar ---- */ ++ if (has_colors()) attron(COLOR_PAIR(2)); ++ mvprintw(rows - 2, 0, ++ " K - UP J - DOWN to navigate Enter - open in less d - delete q - quit (Press J to start) " ++ " "); ++ refresh(); + + /* ---- input ---- */ + int ch = getch(); +diff --git a/bin/gbuild b/bin/gbuild +index 09371a5..e95c8a0 100755 +Binary files a/bin/gbuild and b/bin/gbuild differ +diff --git a/dist/gbuild-1.0.0-1.x86_64.rpm b/dist/gbuild-1.0.0-1.x86_64.rpm +index 6df2d36..611a7ef 100644 +Binary files a/dist/gbuild-1.0.0-1.x86_64.rpm and b/dist/gbuild-1.0.0-1.x86_64.rpm differ +diff --git a/dist/gbuild-1.0.0-x86_64-bin.tar.gz b/dist/gbuild-1.0.0-x86_64-bin.tar.gz +index 60a5227..a8e0d0e 100644 +Binary files a/dist/gbuild-1.0.0-x86_64-bin.tar.gz and b/dist/gbuild-1.0.0-x86_64-bin.tar.gz differ +diff --git a/dist/gbuild-1.0.0.tar.gz b/dist/gbuild-1.0.0.tar.gz +index 3a83082..6010827 100644 +Binary files a/dist/gbuild-1.0.0.tar.gz and b/dist/gbuild-1.0.0.tar.gz differ +diff --git a/dist/gbuild-debuginfo-1.0.0-1.x86_64.rpm b/dist/gbuild-debuginfo-1.0.0-1.x86_64.rpm +index 813ee98..ba7fe6d 100644 +Binary files a/dist/gbuild-debuginfo-1.0.0-1.x86_64.rpm and b/dist/gbuild-debuginfo-1.0.0-1.x86_64.rpm differ +diff --git a/dist/gbuild_1.0.0_amd64.deb b/dist/gbuild_1.0.0_amd64.deb +index 6f52b7e..a365173 100644 +Binary files a/dist/gbuild_1.0.0_amd64.deb and b/dist/gbuild_1.0.0_amd64.deb differ +diff --git a/src/tui.c b/src/tui.c +index b5df72f..c5e660a 100644 +--- a/src/tui.c ++++ b/src/tui.c +@@ -61,7 +61,7 @@ int tui_pick_target(const MakeTargets *targets, char *selected, size_t sel_size) + /* header hint */ + attron(A_DIM); + mvprintw(win_y - 2, win_x, +- "Select make target K - UP J - DOWN to navigate Enter - select \u00b7 q - cancel"); ++ "Select make target \u2191\u2193 navigate \u00b7 Enter select \u00b7 q cancel"); + attroff(A_DIM); + refresh(); + +@@ -189,7 +189,7 @@ static int load_log_entries(const char *log_dir, + * Returns the number of lines read. + * Caller owns the memory and should free each pointer. + */ +-#define PREVIEW_MAX 200 ++#define PREVIEW_MAX 128 + + static int read_tail(const char *path, char **lines, int max_lines) + { +@@ -247,24 +247,24 @@ void tui_log_browser(const char *log_dir) + if (has_colors()) { + start_color(); + use_default_colors(); +- init_pair(1, COLOR_BLACK, COLOR_WHITE); /* selected row */ +- init_pair(2, COLOR_WHITE, -1); /* border */ +- init_pair(3, COLOR_WHITE, -1); /* normal row */ +- init_pair(4, COLOR_WHITE,-1); /* preview text */ +- init_pair(5, COLOR_RED, -1); /* status/warn */ ++ init_pair(1, COLOR_BLACK, COLOR_WHITE); ++ init_pair(2, COLOR_WHITE, -1); ++ init_pair(3, COLOR_WHITE, -1); ++ init_pair(4, COLOR_WHITE, -1); ++ init_pair(5, COLOR_RED, -1); + } + + int rows, cols; + getmaxyx(stdscr, rows, cols); + +- /* layout: left pane = 40% width, right pane = rest */ + int left_w = cols * 40 / 100; + if (left_w < 30) left_w = 30; +- int right_w = cols - left_w - 1; /* -1 for divider */ +- int pane_h = rows - 3; /* -3 for title + hint bar */ ++ int right_w = cols - left_w - 1; ++ int pane_h = rows - 3; + + WINDOW *wleft = newwin(pane_h, left_w, 1, 0); + WINDOW *wright = newwin(pane_h, right_w, 1, left_w + 1); ++ keypad(wleft, TRUE); + + int cur = 0, scroll = 0; + int done = 0; +@@ -279,6 +279,11 @@ void tui_log_browser(const char *log_dir) + attroff(A_BOLD); + clrtoeol(); + ++ /* ---- hint bar ---- */ ++ if (has_colors()) attron(COLOR_PAIR(2)); ++ mvprintw(rows - 2, 0, ++ " K - UP J - DOWN to navigate Enter - open in less d - delete q - quit " ++ " "); + if (has_colors()) attroff(COLOR_PAIR(2)); + + /* ---- vertical divider ---- */ +@@ -299,7 +304,6 @@ void tui_log_browser(const char *log_dir) + } else { + for (int i = 0; i < visible && (i + scroll) < count; i++) { + int idx = i + scroll; +- /* trim the .log extension for display */ + char disp[256]; + strncpy(disp, entries[idx].name, sizeof(disp) - 1); + char *dot = strrchr(disp, '.'); +@@ -318,7 +322,7 @@ void tui_log_browser(const char *log_dir) + } + } + } +- wrefresh(wleft); ++ /* NOTE: no wrefresh(wleft) here — flush everything together below */ + + /* ---- right pane: preview ---- */ + werase(wright); +@@ -345,17 +349,17 @@ void tui_log_browser(const char *log_dir) + } else { + mvwprintw(wright, 2, 2, "(no log selected)"); + } +- wrefresh(wright); + +- refresh(); +- /* ---- hint bar ---- */ +- if (has_colors()) attron(COLOR_PAIR(2)); +- mvprintw(rows - 2, 0, +- " K - UP J - DOWN to navigate Enter - open in less d - delete q - quit (Press J to start) " +- " "); +- ++ /* Flush all layers atomically: stdscr first (background), then the two ++ * panes on top. doupdate() does one physical write so nothing ++ * overwrites anything else. */ ++ wnoutrefresh(stdscr); ++ wnoutrefresh(wleft); ++ wnoutrefresh(wright); ++ doupdate(); ++ + /* ---- input ---- */ +- int ch = getch(); ++ int ch = wgetch(wleft); + switch (ch) { + case KEY_UP: + case 'k': +@@ -381,7 +385,6 @@ void tui_log_browser(const char *log_dir) + snprintf(cmd, sizeof(cmd), + "less \"%s\"", entries[cur].path); + system(cmd); +- /* re-init after less exits */ + initscr(); + noecho(); + cbreak(); +@@ -391,24 +394,24 @@ void tui_log_browser(const char *log_dir) + start_color(); + use_default_colors(); + init_pair(1, COLOR_BLACK, COLOR_WHITE); +- init_pair(2, COLOR_WHITE, -1); ++ init_pair(2, COLOR_WHITE, -1); + init_pair(3, COLOR_WHITE, -1); +- init_pair(4, COLOR_WHITE,-1); ++ init_pair(4, COLOR_WHITE, -1); + init_pair(5, COLOR_RED, -1); + } ++ touchwin(wleft); ++ touchwin(wright); + clear(); + } + break; + case 'd': + if (count > 0 && cur < count) { +- /* confirm */ + mvprintw(rows - 1, 0, + "Delete %s? [y/N] ", entries[cur].name); + refresh(); + int confirm = getch(); + if (confirm == 'y' || confirm == 'Y') { + remove(entries[cur].path); +- /* reload list */ + count = load_log_entries(log_dir, entries, MAX_LOGS); + if (cur >= count) cur = count > 0 ? count - 1 : 0; + if (scroll > cur) scroll = cur; +@@ -417,7 +420,7 @@ void tui_log_browser(const char *log_dir) + } + break; + case 'q': +- case 27: /* ESC */ ++ case 27: + done = 1; + break; + } +diff --git a/Makefile b/Makefile +index 2c21f21..b2e3f5c 100644 +--- a/Makefile ++++ b/Makefile +@@ -30,6 +30,7 @@ COMMON_SRCS = src/config.c + GBUILD_SRCS = \ + src/gbuild.c \ + src/git_ops.c \ ++ src/index.c \ + src/logger.c \ + src/make_ops.c \ + src/tui.c \ +diff --git a/README.md b/README.md +index 0a8ef78..13e5301 100644 +--- a/README.md ++++ b/README.md +@@ -1,4 +1,4 @@ +-# gbuild ++# gbuild — C rewrite + + A C rewrite of the original [gbuild](https://spdlab.hu/gbuild) bash tool. + Clone, pull, pick a target, build — all from one command. +@@ -18,7 +18,7 @@ Clone, pull, pick a target, build — all from one command. + + ## Dependencies + +-| Library | Purpose | ++| Library | Purpose | + |----------|------------------------------| + | ncurses | TUI for picker & log browser | + | git | Clone / pull | +@@ -29,14 +29,9 @@ Clone, pull, pick a target, build — all from one command. + + ## Build + +-### Linux +- + ```sh +-# Install these packages with your package manager +-libncurses-dev make +- +-# Clone this repository +-git clone https://github.com/jokerz/gbuild.git ++# Install ncurses dev headers if needed ++sudo apt install libncurses-dev + + # Build both binaries into bin/ + make +@@ -70,7 +65,7 @@ gbuild myproject + + [git] + GIT_URL = http://localhost:3000 +-GIT_USER = Username ++GIT_USER = + + [auth] + # Use token-based OR password-based auth; leave the other blank +diff --git a/include/git_ops.h b/include/git_ops.h +index bf22f63..bd5ae79 100644 +--- a/include/git_ops.h ++++ b/include/git_ops.h +@@ -16,3 +16,7 @@ int git_clone(const char *base_url, const char *user, + int git_pull(const char *repo_path, Logger *log); + + #endif /* GIT_OPS_H */ ++ ++/* Read the current HEAD hash of repo_path into out (at least 41 bytes). ++ * Returns 0 on success, -1 on failure. */ ++int git_head_hash(const char *repo_path, char *out, size_t n); +diff --git a/include/tui.h b/include/tui.h +index a2c035f..ca6cd1d 100644 +--- a/include/tui.h ++++ b/include/tui.h +@@ -13,4 +13,17 @@ int tui_pick_target(const MakeTargets *targets, char *selected, size_t sel_size + * Keys: ↑↓ navigate Enter open in less d delete q quit */ + void tui_log_browser(const char *log_dir); + ++#include "index.h" ++ ++/* Full-screen project overview. ++ * Lists all projects in idx with last-build status, timestamp and HEAD hash. ++ * On Enter, fills selected_project and returns the row index. ++ * Returns -1 if the user quit without selecting. ++ * Keys: ↑↓ navigate Enter build l log-browser q/ESC quit */ ++int tui_project_overview(ProjectIndex *idx, ++ const char *clone_dir, ++ const char *log_dir, ++ char *selected_project, ++ size_t sel_size); ++ + #endif /* TUI_H */ +diff --git a/src/config.c b/src/config.c +index a89ba18..46b8b5f 100644 +--- a/src/config.c ++++ b/src/config.c +@@ -1,12 +1,3 @@ +-/* +- +-This file is licensed under the MIT license. +- +-Copyright (c) Schmidt Peter Daniel 2026 +- +- +-*/ +- + #include "config.h" + + #include +diff --git a/src/gbuild.c b/src/gbuild.c +index 9bc8b8a..ff8514e 100644 +--- a/src/gbuild.c ++++ b/src/gbuild.c +@@ -1,5 +1,6 @@ + #include "config.h" + #include "git_ops.h" ++#include "index.h" + #include "logger.h" + #include "make_ops.h" + #include "tui.h" +@@ -20,20 +21,126 @@ static void print_usage(const char *prog) + "gbuild %s — A build tool for your Linux distro\n\n" + "Usage:\n" + " %s [options] \n" ++ " %s (no args — opens project overview TUI)\n" + " %s --logs\n\n" + "Options:\n" +- " --url Override Git base URL (default: ~/.gconfig)\n" +- " --user Override Git username (default: ~/.gconfig)\n" +- " --target Run target directly, skip interactive picker\n" ++ " --url Override Git base URL (default: ~/.gconfig)\n" ++ " --user Override Git username (default: ~/.gconfig)\n" ++ " --target Run target directly, skip interactive picker\n" + " --logs Open the interactive log browser\n" ++ " --no-tui With no , print usage instead of opening TUI\n" + " -h, --help Print this help and exit\n\n" + "Examples:\n" ++ " gbuild (overview TUI — pick and build)\n" + " gbuild myproject\n" + " gbuild --target clean myproject\n" + " gbuild --url http://10.0.0.5:3000 --user alice myproject\n" + " gbuild --logs\n\n" + "Configuration is read from ~/.gconfig. Run 'gconfig init' to set it up.\n", +- VERSION, prog, prog); ++ VERSION, prog, prog, prog); ++} ++ ++/* ----------------------------------------------------------------- build one project */ ++ ++static int build_project(const char *project, ++ const char *arg_target, ++ GConfig *cfg, ++ const char *clone_dir, ++ const char *log_dir, ++ ProjectIndex *idx) ++{ ++ /* ---- open logger ---- */ ++ Logger log; ++ memset(&log, 0, sizeof(log)); ++ if (cfg->log_enabled) { ++ if (logger_open(&log, log_dir, project) == 0) ++ logger_info(&log, "Logging to: %s", log.path); ++ else ++ fprintf(stderr, "[WARN ] Could not open log file in %s\n", log_dir); ++ } ++ ++ logger_info(&log, "gbuild started for project: %s", project); ++ ++ /* ---- clone or pull ---- */ ++ char repo_path[CFG_MAX * 2]; ++ snprintf(repo_path, sizeof(repo_path), "%s/%s", clone_dir, project); ++ ++ if (git_repo_exists(clone_dir, project)) { ++ logger_info(&log, "Repo already cloned — pulling latest changes ..."); ++ int rc = git_pull(repo_path, &log); ++ if (rc != 0) { ++ logger_error(&log, "git pull failed (exit %d).", rc); ++ logger_close(&log); ++ return 1; ++ } ++ logger_ok(&log, "Repository updated."); ++ } else { ++ logger_info(&log, "Cloning %s/%s/%s ...", ++ cfg->git_url, cfg->git_user, project); ++ int rc = git_clone(cfg->git_url, cfg->git_user, ++ cfg->git_token, cfg->git_password, ++ project, clone_dir, &log); ++ if (rc != 0) { ++ logger_error(&log, "git clone failed (exit %d).", rc); ++ logger_close(&log); ++ return 1; ++ } ++ logger_ok(&log, "Repository cloned."); ++ } ++ ++ /* ---- record HEAD hash ---- */ ++ ProjectRecord *rec = index_upsert(idx, project); ++ if (rec) ++ git_head_hash(repo_path, rec->last_head_hash, IDX_HASH_LEN); ++ ++ /* ---- resolve make target ---- */ ++ char target[TARGET_NAME]; ++ memset(target, 0, sizeof(target)); ++ ++ if (arg_target) { ++ strncpy(target, arg_target, sizeof(target) - 1); ++ logger_info(&log, "Selected target: %s", target); ++ } else if (cfg->default_target[0]) { ++ strncpy(target, cfg->default_target, sizeof(target) - 1); ++ logger_info(&log, "Using default target: %s", target); ++ } else { ++ MakeTargets targets; ++ if (make_parse_targets(repo_path, &targets) != 0) { ++ logger_warn(&log, ++ "No targets found in Makefile — running 'make' with no target."); ++ target[0] = '\0'; ++ } else { ++ if (tui_pick_target(&targets, target, sizeof(target)) < 0) { ++ logger_warn(&log, "No target selected — aborting."); ++ logger_close(&log); ++ return 0; ++ } ++ } ++ logger_info(&log, "Selected target: %s", ++ target[0] ? target : "(default)"); ++ } ++ ++ /* ---- build ---- */ ++ logger_info(&log, "Building '%s' target '%s' ...", ++ project, target[0] ? target : "(default)"); ++ ++ int rc = make_build(repo_path, target, &log); ++ ++ /* ---- update index ---- */ ++ if (rec) { ++ rec->last_build_rc = rc; ++ rec->last_build_ts = time(NULL); ++ } ++ ++ if (rc != 0) { ++ logger_error(&log, "Build failed (exit %d).", rc); ++ logger_close(&log); ++ return 1; ++ } ++ ++ logger_ok(&log, "gbuild complete."); ++ logger_close(&log); ++ return 0; + } + + /* ----------------------------------------------------------------- main */ +@@ -46,6 +153,7 @@ int main(int argc, char *argv[]) + const char *arg_target = NULL; + const char *arg_project = NULL; + int flag_logs = 0; ++ int flag_no_tui = 0; + + for (int i = 1; i < argc; i++) { + if (!strcmp(argv[i], "-h") || !strcmp(argv[i], "--help")) { +@@ -62,6 +170,8 @@ int main(int argc, char *argv[]) + arg_target = argv[i]; + } else if (!strcmp(argv[i], "--logs")) { + flag_logs = 1; ++ } else if (!strcmp(argv[i], "--no-tui")) { ++ flag_no_tui = 1; + } else if (argv[i][0] == '-') { + fprintf(stderr, "error: unknown flag '%s'\n", argv[i]); + return 1; +@@ -86,109 +196,54 @@ int main(int argc, char *argv[]) + config_defaults(&cfg); + } + +- /* apply overrides */ + if (arg_url) strncpy(cfg.git_url, arg_url, CFG_MAX - 1); + if (arg_user) strncpy(cfg.git_user, arg_user, sizeof(cfg.git_user) - 1); + +- /* expand paths that might contain ~ */ + char clone_dir[CFG_MAX], log_dir[CFG_MAX]; + expand_tilde(cfg.clone_dir, clone_dir, sizeof(clone_dir)); + expand_tilde(cfg.log_dir, log_dir, sizeof(log_dir)); + ++ /* ---- load / scan project index ---- */ ++ ProjectIndex idx; ++ char idx_path[512]; ++ index_get_path(idx_path, sizeof(idx_path)); ++ index_load(&idx, idx_path); /* ok if file doesn't exist yet */ ++ index_scan(&idx, clone_dir); /* pick up any new repos on disk */ ++ + /* ---- log browser mode ---- */ + if (flag_logs) { + tui_log_browser(log_dir); + return 0; + } + +- /* ---- need a project name ---- */ ++ /* ---- no project given: open overview TUI or print usage ---- */ + if (!arg_project) { +- fprintf(stderr, "error: no project specified.\n\n"); +- print_usage(argv[0]); +- return 1; +- } +- +- /* ---- open logger ---- */ +- Logger log; +- memset(&log, 0, sizeof(log)); +- if (cfg.log_enabled) { +- if (logger_open(&log, log_dir, arg_project) == 0) +- logger_info(&log, "Logging to: %s", log.path); +- else +- fprintf(stderr, "[WARN ] Could not open log file in %s\n", log_dir); +- } +- +- logger_info(&log, "gbuild started for project: %s", arg_project); +- +- /* ---- clone or pull ---- */ +- char repo_path[CFG_MAX * 2]; +- snprintf(repo_path, sizeof(repo_path), "%s/%s", clone_dir, arg_project); +- +- if (git_repo_exists(clone_dir, arg_project)) { +- logger_info(&log, "Repo already cloned — pulling latest changes ..."); +- int rc = git_pull(repo_path, &log); +- if (rc != 0) { +- logger_error(&log, "git pull failed (exit %d).", rc); +- logger_close(&log); +- return 1; ++ if (flag_no_tui || idx.count == 0) { ++ if (idx.count == 0 && !flag_no_tui) ++ fprintf(stderr, ++ "No projects found in %s.\n" ++ "Run 'gbuild ' to clone and build one first.\n", ++ clone_dir); ++ else ++ print_usage(argv[0]); ++ return (idx.count == 0) ? 1 : 0; + } +- logger_ok(&log, "Repository updated."); +- } else { +- logger_info(&log, "Cloning %s/%s/%s ...", +- cfg.git_url, cfg.git_user, arg_project); +- int rc = git_clone(cfg.git_url, cfg.git_user, +- cfg.git_token, cfg.git_password, +- arg_project, clone_dir, &log); +- if (rc != 0) { +- logger_error(&log, "git clone failed (exit %d).", rc); +- logger_close(&log); +- return 1; +- } +- logger_ok(&log, "Repository cloned."); +- } + +- /* ---- resolve make target ---- */ +- char target[TARGET_NAME]; +- memset(target, 0, sizeof(target)); ++ static char chosen[IDX_NAME_LEN]; ++ memset(chosen, 0, sizeof(chosen)); ++ if (tui_project_overview(&idx, clone_dir, log_dir, ++ chosen, sizeof(chosen)) < 0) ++ return 0; /* user quit without picking */ + +- if (arg_target) { +- /* explicit --target flag */ +- strncpy(target, arg_target, sizeof(target) - 1); +- logger_info(&log, "Selected target: %s", target); +- } else if (cfg.default_target[0]) { +- /* default from config */ +- strncpy(target, cfg.default_target, sizeof(target) - 1); +- logger_info(&log, "Using default target: %s", target); +- } else { +- /* interactive TUI picker */ +- MakeTargets targets; +- if (make_parse_targets(repo_path, &targets) != 0) { +- logger_warn(&log, +- "No targets found in Makefile — running 'make' with no target."); +- target[0] = '\0'; +- } else { +- if (tui_pick_target(&targets, target, sizeof(target)) < 0) { +- logger_warn(&log, "No target selected — aborting."); +- logger_close(&log); +- return 0; +- } +- } +- logger_info(&log, "Selected target: %s", +- target[0] ? target : "(default)"); ++ arg_project = chosen; + } + + /* ---- build ---- */ +- logger_info(&log, "gbuild: building '%s' target '%s' ...", +- arg_project, target[0] ? target : "(default)"); ++ int rc = build_project(arg_project, arg_target, ++ &cfg, clone_dir, log_dir, &idx); + +- int rc = make_build(repo_path, target, &log); +- if (rc != 0) { +- logger_error(&log, "Build failed (exit %d).", rc); +- logger_close(&log); +- return 1; +- } ++ /* ---- persist updated index ---- */ ++ index_save(&idx, idx_path); + +- logger_ok(&log, "gbuild complete."); +- logger_close(&log); +- return 0; ++ return rc; + } +diff --git a/src/git_ops.c b/src/git_ops.c +index 69588eb..3f995f1 100644 +--- a/src/git_ops.c ++++ b/src/git_ops.c +@@ -1,13 +1,6 @@ +-/* +- +-This file is licensed under the MIT license. +-Copyright (c) Schmidt Peter Daniel 2026. +- +- +-*/ +- + #include "git_ops.h" + #include "config.h" ++#include "index.h" + + #include + #include +@@ -131,3 +124,27 @@ int git_pull(const char *repo_path, Logger *log) + "git -C \"%s\" pull 2>&1", repo_path); + return run_stream(cmd, log); + } ++ ++int git_head_hash(const char *repo_path, char *out, size_t n) ++{ ++ char cmd[IDX_PATH_LEN + 64]; ++ snprintf(cmd, sizeof(cmd), ++ "git -C \"%s\" rev-parse HEAD 2>/dev/null", repo_path); ++ ++ FILE *p = popen(cmd, "r"); ++ if (!p) return -1; ++ ++ char buf[64] = {0}; ++ int ok = (fgets(buf, sizeof(buf), p) != NULL); ++ pclose(p); ++ ++ if (!ok || buf[0] == '\0') return -1; ++ ++ /* strip newline */ ++ size_t l = strlen(buf); ++ if (l > 0 && buf[l-1] == '\n') buf[--l] = '\0'; ++ ++ strncpy(out, buf, n - 1); ++ out[n - 1] = '\0'; ++ return 0; ++} +diff --git a/src/logger.c b/src/logger.c +index 997a975..de2eead 100644 +--- a/src/logger.c ++++ b/src/logger.c +@@ -71,9 +71,9 @@ typedef enum { LVL_INFO, LVL_OK, LVL_WARN, LVL_ERROR } LogLevel; + static const char *level_tag(LogLevel lv) + { + switch (lv) { +- case LVL_INFO: return "[INFO]"; +- case LVL_OK: return "[OK]"; +- case LVL_WARN: return "[WARN]"; ++ case LVL_INFO: return "[INFO ]"; ++ case LVL_OK: return "[OK ]"; ++ case LVL_WARN: return "[WARN ]"; + case LVL_ERROR: return "[ERROR]"; + } + return "[ ]"; +diff --git a/src/tui.c b/src/tui.c +index c5e660a..605fba6 100644 +--- a/src/tui.c ++++ b/src/tui.c +@@ -10,7 +10,7 @@ + #include + + /* ================================================================ +- * SECTION 1 — TARGET PICKER ++ * SECTION 1 - TARGET PICKER + * ================================================================ */ + + #define PICKER_MIN_W 40 +@@ -29,18 +29,15 @@ int tui_pick_target(const MakeTargets *targets, char *selected, size_t sel_size) + if (has_colors()) { + start_color(); + use_default_colors(); +- init_pair(1, COLOR_BLACK, COLOR_WHITE); /* selected row */ +- init_pair(2, COLOR_WHITE, -1); /* border colour */ +- init_pair(3, COLOR_WHITE, -1); /* normal row */ ++ init_pair(1, COLOR_BLACK, COLOR_WHITE); /* selected row */ + } + + int rows, cols; + getmaxyx(stdscr, rows, cols); + +- int list_h = targets->count + 2; /* +2 for border */ ++ int list_h = targets->count + 2; + int list_w = PICKER_MIN_W; + +- /* find longest target name and widen accordingly */ + for (int i = 0; i < targets->count; i++) { + int l = (int)strlen(targets->names[i]) + 4; + if (l > list_w) list_w = l; +@@ -51,17 +48,16 @@ int tui_pick_target(const MakeTargets *targets, char *selected, size_t sel_size) + int win_y = (rows - list_h) / 2; + int win_x = (cols - list_w) / 2; + +- /* instruction line above the box */ + int cur = 0; + int scroll_offset = 0; +- int visible = list_h - 2; /* rows inside border */ ++ int visible = list_h - 2; + + WINDOW *win = newwin(list_h, list_w, win_y, win_x); ++ keypad(win, TRUE); + +- /* header hint */ + attron(A_DIM); + mvprintw(win_y - 2, win_x, +- "Select make target \u2191\u2193 navigate \u00b7 Enter select \u00b7 q cancel"); ++ "Select make target J/K navigate | Enter select | q cancel"); + attroff(A_DIM); + refresh(); + +@@ -70,9 +66,7 @@ int tui_pick_target(const MakeTargets *targets, char *selected, size_t sel_size) + + while (!done) { + werase(win); +- if (has_colors()) wattron(win, COLOR_PAIR(2)); + box(win, 0, 0); +- if (has_colors()) wattroff(win, COLOR_PAIR(2)); + + for (int i = 0; i < visible && (i + scroll_offset) < targets->count; i++) { + int idx = i + scroll_offset; +@@ -84,10 +78,8 @@ int tui_pick_target(const MakeTargets *targets, char *selected, size_t sel_size) + if (has_colors()) wattroff(win, COLOR_PAIR(1) | A_BOLD); + else wattroff(win, A_REVERSE); + } else { +- if (has_colors()) wattron(win, COLOR_PAIR(3)); + mvwprintw(win, i + 1, 1, " %-*s", + list_w - 4, targets->names[idx]); +- if (has_colors()) wattroff(win, COLOR_PAIR(3)); + } + } + +@@ -119,7 +111,7 @@ int tui_pick_target(const MakeTargets *targets, char *selected, size_t sel_size) + done = 1; + break; + case 'q': +- case 27: /* ESC */ ++ case 27: + done = 1; + break; + } +@@ -131,20 +123,19 @@ int tui_pick_target(const MakeTargets *targets, char *selected, size_t sel_size) + } + + /* ================================================================ +- * SECTION 2 — LOG BROWSER ++ * SECTION 2 - LOG BROWSER + * ================================================================ */ + + #define MAX_LOGS 512 + + typedef struct { +- char name[256]; /* filename only */ +- char path[768]; /* full path */ ++ char name[256]; ++ char path[768]; + time_t mtime; + } LogEntry; + + static int log_entry_cmp(const void *a, const void *b) + { +- /* newest first */ + const LogEntry *la = (const LogEntry *)a; + const LogEntry *lb = (const LogEntry *)b; + if (lb->mtime > la->mtime) return 1; +@@ -162,7 +153,6 @@ static int load_log_entries(const char *log_dir, + struct dirent *de; + while ((de = readdir(d)) && count < max) { + if (de->d_name[0] == '.') continue; +- /* only .log files */ + const char *dot = strrchr(de->d_name, '.'); + if (!dot || strcmp(dot, ".log")) continue; + +@@ -184,11 +174,6 @@ static int load_log_entries(const char *log_dir, + return count; + } + +-/* +- * Read up to `max_lines` trailing lines from `path` into `lines`. +- * Returns the number of lines read. +- * Caller owns the memory and should free each pointer. +- */ + #define PREVIEW_MAX 128 + + static int read_tail(const char *path, char **lines, int max_lines) +@@ -196,17 +181,14 @@ static int read_tail(const char *path, char **lines, int max_lines) + FILE *f = fopen(path, "r"); + if (!f) return 0; + +- /* Circular buffer approach */ + char **buf = calloc(max_lines, sizeof(char *)); + if (!buf) { fclose(f); return 0; } + + char tmp[1024]; + int idx = 0, total = 0; + while (fgets(tmp, sizeof(tmp), f)) { +- /* strip newline */ + size_t l = strlen(tmp); + if (l > 0 && tmp[l-1] == '\n') tmp[l-1] = '\0'; +- + free(buf[idx % max_lines]); + buf[idx % max_lines] = strdup(tmp); + idx++; +@@ -214,13 +196,12 @@ static int read_tail(const char *path, char **lines, int max_lines) + } + fclose(f); + +- int have = (total < max_lines) ? total : max_lines; ++ int have = (total < max_lines) ? total : max_lines; + int start = (total >= max_lines) ? (idx % max_lines) : 0; + + for (int i = 0; i < have; i++) + lines[i] = buf[(start + i) % max_lines]; + +- /* free slots that weren't returned */ + for (int i = have; i < max_lines; i++) { + free(buf[(start + i) % max_lines]); + buf[(start + i) % max_lines] = NULL; +@@ -247,11 +228,7 @@ void tui_log_browser(const char *log_dir) + if (has_colors()) { + start_color(); + use_default_colors(); +- init_pair(1, COLOR_BLACK, COLOR_WHITE); +- init_pair(2, COLOR_WHITE, -1); +- init_pair(3, COLOR_WHITE, -1); +- init_pair(4, COLOR_WHITE, -1); +- init_pair(5, COLOR_RED, -1); ++ init_pair(1, COLOR_BLACK, COLOR_WHITE); /* selected row */ + } + + int rows, cols; +@@ -280,11 +257,11 @@ void tui_log_browser(const char *log_dir) + clrtoeol(); + + /* ---- hint bar ---- */ +- if (has_colors()) attron(COLOR_PAIR(2)); ++ attron(A_REVERSE); + mvprintw(rows - 2, 0, +- " K - UP J - DOWN to navigate Enter - open in less d - delete q - quit " ++ " J/K navigate Enter open in less d delete q quit " + " "); +- if (has_colors()) attroff(COLOR_PAIR(2)); ++ attroff(A_REVERSE); + + /* ---- vertical divider ---- */ + for (int y = 1; y < rows - 2; y++) +@@ -292,9 +269,7 @@ void tui_log_browser(const char *log_dir) + + /* ---- left pane: file list ---- */ + werase(wleft); +- if (has_colors()) wattron(wleft, COLOR_PAIR(2)); + box(wleft, 0, 0); +- if (has_colors()) wattroff(wleft, COLOR_PAIR(2)); + + int visible = pane_h - 2; + if (visible < 1) visible = 1; +@@ -322,20 +297,17 @@ void tui_log_browser(const char *log_dir) + } + } + } +- /* NOTE: no wrefresh(wleft) here — flush everything together below */ ++ /* NOTE: no wrefresh(wleft) here - flush everything together below */ + + /* ---- right pane: preview ---- */ + werase(wright); +- if (has_colors()) wattron(wright, COLOR_PAIR(2)); + box(wright, 0, 0); +- if (has_colors()) wattroff(wright, COLOR_PAIR(2)); + + if (count > 0 && cur < count) { + int preview_lines = pane_h - 2; + char **lines = calloc(preview_lines, sizeof(char *)); + if (lines) { + int n = read_tail(entries[cur].path, lines, preview_lines); +- if (has_colors()) wattron(wright, COLOR_PAIR(4)); + for (int i = 0; i < n; i++) { + if (lines[i]) { + mvwprintw(wright, i + 1, 1, "%-*.*s", +@@ -343,16 +315,13 @@ void tui_log_browser(const char *log_dir) + free(lines[i]); + } + } +- if (has_colors()) wattroff(wright, COLOR_PAIR(4)); + free(lines); + } + } else { + mvwprintw(wright, 2, 2, "(no log selected)"); + } + +- /* Flush all layers atomically: stdscr first (background), then the two +- * panes on top. doupdate() does one physical write so nothing +- * overwrites anything else. */ ++ /* Atomic flush - stdscr first, then both panes on top */ + wnoutrefresh(stdscr); + wnoutrefresh(wleft); + wnoutrefresh(wright); +@@ -394,10 +363,6 @@ void tui_log_browser(const char *log_dir) + start_color(); + use_default_colors(); + init_pair(1, COLOR_BLACK, COLOR_WHITE); +- init_pair(2, COLOR_WHITE, -1); +- init_pair(3, COLOR_WHITE, -1); +- init_pair(4, COLOR_WHITE, -1); +- init_pair(5, COLOR_RED, -1); + } + touchwin(wleft); + touchwin(wright); +@@ -431,3 +396,202 @@ void tui_log_browser(const char *log_dir) + endwin(); + free(entries); + } ++ ++/* ================================================================ ++ * SECTION 3 - PROJECT OVERVIEW ++ * ================================================================ */ ++ ++/* ++ * Column layout: ++ * ST PROJECT NAME LAST BUILD RESULT HEAD HASH ++ * ++ * Status symbols: ++ * * last build passed ++ * X last build failed ++ * - never built ++ * ++ * Keys: ++ * J/K navigate Enter build l logs q/ESC quit ++ */ ++ ++#include "index.h" ++#include "git_ops.h" ++ ++#define COL_STATUS_W 3 ++#define COL_NAME_W 24 ++#define COL_TIME_W 18 ++#define COL_RC_W 8 ++#define COL_HASH_W 10 ++ ++static void fmt_ts(time_t ts, char *out, size_t n) ++{ ++ if (ts == 0) { ++ strncpy(out, "(never)", n - 1); ++ out[n-1] = '\0'; ++ return; ++ } ++ struct tm *t = localtime(&ts); ++ strftime(out, n, "%d %b %H:%M", t); ++} ++ ++int tui_project_overview(ProjectIndex *idx, ++ const char *clone_dir, ++ const char *log_dir, ++ char *selected_project, ++ size_t sel_size) ++{ ++ (void)clone_dir; ++ ++ if (!idx || idx->count == 0) return -1; ++ ++ initscr(); ++ noecho(); ++ cbreak(); ++ keypad(stdscr, TRUE); ++ curs_set(0); ++ ++ if (has_colors()) { ++ start_color(); ++ use_default_colors(); ++ init_pair(1, COLOR_BLACK, COLOR_WHITE); /* selected row */ ++ } ++ ++ int rows, cols; ++ getmaxyx(stdscr, rows, cols); ++ ++ int cur = 0, scroll = 0; ++ int done = 0; ++ int result = -1; ++ ++ while (!done) { ++ getmaxyx(stdscr, rows, cols); ++ int visible = rows - 5; ++ if (visible < 1) visible = 1; ++ ++ erase(); ++ ++ /* ---- title bar ---- */ ++ attron(A_BOLD); ++ mvprintw(0, 2, "gbuild 1.0.0 - project overview"); ++ attroff(A_BOLD); ++ ++ /* ---- column header ---- */ ++ attron(A_BOLD); ++ mvprintw(2, 2, " %-*s %-*s %-*s %-*s", ++ COL_NAME_W, "PROJECT", ++ COL_TIME_W, "LAST BUILD", ++ COL_RC_W, "RESULT", ++ COL_HASH_W, "HEAD"); ++ attroff(A_BOLD); ++ ++ /* separator */ ++ mvhline(3, 0, ACS_HLINE, cols); ++ ++ /* ---- project rows ---- */ ++ for (int i = 0; i < visible && (i + scroll) < idx->count; i++) { ++ int idx_i = i + scroll; ++ ProjectRecord *r = &idx->projects[idx_i]; ++ ++ char ts_str[32]; ++ fmt_ts(r->last_build_ts, ts_str, sizeof(ts_str)); ++ ++ char shorthash[12] = "--------"; ++ if (r->last_head_hash[0]) ++ strncpy(shorthash, r->last_head_hash, 8); ++ shorthash[8] = '\0'; ++ ++ char rc_str[16]; ++ if (r->last_build_rc == -1) strncpy(rc_str, "-", sizeof(rc_str) - 1); ++ else if (r->last_build_rc == 0) strncpy(rc_str, "PASS", sizeof(rc_str) - 1); ++ else snprintf(rc_str, sizeof(rc_str), "FAIL(%d)", r->last_build_rc); ++ ++ const char *sym = (r->last_build_rc == 0) ? "*" : ++ (r->last_build_rc > 0) ? "X" : "-"; ++ ++ int y = 4 + i; ++ ++ if (idx_i == cur) { ++ /* fill the whole row first */ ++ if (has_colors()) attron(COLOR_PAIR(1) | A_BOLD); ++ else attron(A_REVERSE); ++ mvprintw(y, 0, "%*s", cols, ""); ++ mvprintw(y, 2, "%s %-*s %-*s %-*s %-*s", ++ sym, ++ COL_NAME_W, r->name, ++ COL_TIME_W, ts_str, ++ COL_RC_W, rc_str, ++ COL_HASH_W, shorthash); ++ if (has_colors()) attroff(COLOR_PAIR(1) | A_BOLD); ++ else attroff(A_REVERSE); ++ } else { ++ mvprintw(y, 2, "%s %-*s %-*s %-*s %-*s", ++ sym, ++ COL_NAME_W, r->name, ++ COL_TIME_W, ts_str, ++ COL_RC_W, rc_str, ++ COL_HASH_W, shorthash); ++ } ++ } ++ ++ /* ---- hint bar ---- */ ++ attron(A_REVERSE); ++ mvprintw(rows - 1, 0, ++ " J/K navigate Enter build l logs q quit" ++ " "); ++ attroff(A_REVERSE); ++ ++ wnoutrefresh(stdscr); ++ doupdate(); ++ ++ /* ---- input ---- */ ++ int ch = getch(); ++ switch (ch) { ++ case KEY_UP: ++ case 'k': ++ if (cur > 0) { ++ cur--; ++ if (cur < scroll) scroll = cur; ++ } ++ break; ++ case KEY_DOWN: ++ case 'j': ++ if (cur < idx->count - 1) { ++ cur++; ++ if (cur >= scroll + visible) ++ scroll = cur - visible + 1; ++ } ++ break; ++ case '\n': ++ case '\r': ++ case KEY_ENTER: ++ strncpy(selected_project, ++ idx->projects[cur].name, sel_size - 1); ++ selected_project[sel_size - 1] = '\0'; ++ result = cur; ++ done = 1; ++ break; ++ case 'l': ++ endwin(); ++ tui_log_browser(log_dir); ++ initscr(); ++ noecho(); ++ cbreak(); ++ keypad(stdscr, TRUE); ++ curs_set(0); ++ if (has_colors()) { ++ start_color(); ++ use_default_colors(); ++ init_pair(1, COLOR_BLACK, COLOR_WHITE); ++ } ++ clear(); ++ break; ++ case 'q': ++ case 27: ++ done = 1; ++ break; ++ } ++ } ++ ++ endwin(); ++ return result; ++} +diff --git a/bin/gbuild b/bin/gbuild +deleted file mode 100755 +index 23d9fe9..0000000 +Binary files a/bin/gbuild and /dev/null differ +diff --git a/bin/gconfig b/bin/gconfig +deleted file mode 100755 +index 2b1ee61..0000000 +Binary files a/bin/gconfig and /dev/null differ +diff --git a/dist/gbuild-1.0.0-1.x86_64.rpm b/dist/gbuild-1.0.0-1.x86_64.rpm +deleted file mode 100644 +index f0d9432..0000000 +Binary files a/dist/gbuild-1.0.0-1.x86_64.rpm and /dev/null differ +diff --git a/dist/gbuild-1.0.0-x86_64-bin.tar.gz b/dist/gbuild-1.0.0-x86_64-bin.tar.gz +deleted file mode 100644 +index 40bbc22..0000000 +Binary files a/dist/gbuild-1.0.0-x86_64-bin.tar.gz and /dev/null differ +diff --git a/dist/gbuild-1.0.0.tar.gz b/dist/gbuild-1.0.0.tar.gz +deleted file mode 100644 +index 3cc865a..0000000 +Binary files a/dist/gbuild-1.0.0.tar.gz and /dev/null differ +diff --git a/dist/gbuild-debuginfo-1.0.0-1.x86_64.rpm b/dist/gbuild-debuginfo-1.0.0-1.x86_64.rpm +deleted file mode 100644 +index 4903e10..0000000 +Binary files a/dist/gbuild-debuginfo-1.0.0-1.x86_64.rpm and /dev/null differ +diff --git a/dist/gbuild_1.0.0_amd64.deb b/dist/gbuild_1.0.0_amd64.deb +deleted file mode 100644 +index 0a722ed..0000000 +Binary files a/dist/gbuild_1.0.0_amd64.deb and /dev/null differ diff --git a/include/cache.h b/include/cache.h new file mode 100644 index 0000000..97b6c26 --- /dev/null +++ b/include/cache.h @@ -0,0 +1,35 @@ +#ifndef CACHE_H +#define CACHE_H + +/* + * Build cache — per-project dirty detection. + * + * A small dotfile (.gbuild_cache) is kept inside each cloned repo dir. + * It stores the git HEAD hash that was current at the last *successful* + * build. On the next run gbuild compares the live HEAD to the cached + * one; if they match the build is skipped unless --force was passed. + * + * Format of .gbuild_cache (plain text, one line): + * <40-char sha1>\n + */ + +#include + +/* Maximum length of a stored hash (SHA-1 hex + NUL). */ +#define CACHE_HASH_LEN 64 + +/* + * Read the cached hash for the repo at repo_path into out (>= CACHE_HASH_LEN + * bytes). Returns 0 on success, -1 if the file does not exist or is + * unreadable (out is set to an empty string in that case). + */ +int cache_read(const char *repo_path, char *out, size_t n); + +/* + * Write hash as the new cached HEAD for the repo at repo_path. + * Creates or overwrites .gbuild_cache inside repo_path. + * Returns 0 on success, -1 on error. + */ +int cache_write(const char *repo_path, const char *hash); + +#endif /* CACHE_H */ diff --git a/include/config.h b/include/config.h index c3a911d..9192c59 100644 --- a/include/config.h +++ b/include/config.h @@ -6,6 +6,18 @@ #define CFG_MAX 512 +/* + * Per-project hook override loaded from a [project:] section. + * Up to CFG_MAX_PROJECT_OVERRIDES overrides are supported. + */ +#define CFG_MAX_PROJECT_OVERRIDES 64 +#define CFG_PROJECT_NAME_LEN 128 + +typedef struct { + char name[CFG_PROJECT_NAME_LEN]; /* project name, e.g. "myproject" */ + char post_build_hook[CFG_MAX]; /* hook command for this project */ +} GProjectOverride; + typedef struct { char git_url[CFG_MAX]; char git_user[256]; @@ -15,8 +27,24 @@ typedef struct { char clone_dir[CFG_MAX]; bool log_enabled; char log_dir[CFG_MAX]; + + /* Post-build hook run after every successful make invocation. + * The hook is executed with the repo directory as its CWD. + * An empty string means "no hook". */ + char post_build_hook[CFG_MAX]; + + /* Per-project hook overrides from [project:] sections. */ + GProjectOverride project_overrides[CFG_MAX_PROJECT_OVERRIDES]; + int project_override_count; } GConfig; +/* + * Return the effective post-build hook for a given project name. + * Checks [project:] overrides first; falls back to the global hook. + * Returns a pointer into cfg — do not free. + */ +const char *config_hook_for(const GConfig *cfg, const char *project); + void config_defaults(GConfig *cfg); int config_load(GConfig *cfg, const char *path); int config_save(const GConfig *cfg, const char *path); diff --git a/include/git_ops.h b/include/git_ops.h index bf22f63..d0735a2 100644 --- a/include/git_ops.h +++ b/include/git_ops.h @@ -15,4 +15,20 @@ int git_clone(const char *base_url, const char *user, /* Pull latest in repo_path */ int git_pull(const char *repo_path, Logger *log); +/* Read the current HEAD hash of repo_path into out (at least 41 bytes). + * Returns 0 on success, -1 on failure. */ +int git_head_hash(const char *repo_path, char *out, size_t n); + +/* Write the current branch name into out. + * Returns 0 on success, -1 on failure (detached HEAD writes "(detached)"). */ +int git_current_branch(const char *repo_path, char *out, size_t n); + +/* Returns 1 if the working tree has uncommitted changes, 0 if clean, + * -1 on error. Does NOT stage anything. */ +int git_is_dirty(const char *repo_path); + +/* Silently fetches from origin then returns the number of commits + * the local branch is behind its upstream, or -1 on error. */ +int git_behind_count(const char *repo_path); + #endif /* GIT_OPS_H */ diff --git a/include/hooks.h b/include/hooks.h new file mode 100644 index 0000000..6ddd6a0 --- /dev/null +++ b/include/hooks.h @@ -0,0 +1,18 @@ +#ifndef HOOKS_H +#define HOOKS_H + +#include "logger.h" + +/* + * Run cmd in a shell with working directory set to working_dir. + * stdout and stderr from the hook are streamed through log. + * + * Returns the hook's exit status (0 = success, >0 = hook failure), + * or -1 if the shell could not be launched. + * + * The caller is responsible for distinguishing hook failure from build + * failure — this function never touches build state. + */ +int hook_run(const char *cmd, const char *working_dir, Logger *log); + +#endif /* HOOKS_H */ diff --git a/include/index.h b/include/index.h new file mode 100644 index 0000000..0cae70b --- /dev/null +++ b/include/index.h @@ -0,0 +1,82 @@ +#ifndef INDEX_H +#define INDEX_H + +#include +#include + +#define IDX_MAX_PROJECTS 256 +#define IDX_NAME_LEN 128 +#define IDX_HASH_LEN 64 +#define IDX_PATH_LEN 512 + +/* + * Per-project record stored in ~/.gbuild_index. + * + * File format (INI-style, one section per project): + * + * [myproject] + * last_build_rc = 0 + * last_build_ts = 1716000000 + * last_head_hash = abc123def456 + */ +typedef struct { + char name[IDX_NAME_LEN]; + int last_build_rc; /* exit code of last make_build(); -1 = never built */ + time_t last_build_ts; /* unix timestamp of last build attempt */ + char last_head_hash[IDX_HASH_LEN]; /* git HEAD hash at last build */ +} ProjectRecord; + +typedef struct { + ProjectRecord projects[IDX_MAX_PROJECTS]; + int count; +} ProjectIndex; + +/* Load ~/.gbuild_index into idx. Returns 0 on success, -1 if not found + * (idx is still initialised to an empty index). */ +int index_load(ProjectIndex *idx, const char *path); + +/* Persist idx to path, creating or overwriting the file. */ +int index_save(const ProjectIndex *idx, const char *path); + +/* Find a record by name. Returns a pointer into idx->projects, or NULL. */ +ProjectRecord *index_find(ProjectIndex *idx, const char *name); + +/* Find or create a record by name. Returns NULL only if the index is full. */ +ProjectRecord *index_upsert(ProjectIndex *idx, const char *name); + +/* Walk clone_dir, find every subdir that contains .git, and upsert each one + * into idx. Does NOT overwrite existing build metadata — only adds new + * entries for projects that are not yet tracked. + * Returns the number of new entries added. */ +int index_scan(ProjectIndex *idx, const char *clone_dir); + +/* Canonical path for the index file (~/.gbuild_index). */ +void index_get_path(char *out, size_t n); + +/* ---------------------------------------------------------------- status scan */ + +/* + * Per-project status gathered by project_scan_all(). + * All string fields are NUL-terminated; numeric fields are -1 when unknown. + */ +typedef struct { + char name[IDX_NAME_LEN]; + char branch[128]; /* current branch / "(detached:HASH)" */ + int behind; /* commits behind upstream; -1 = no upstream/error */ + int dirty; /* 1 = dirty, 0 = clean, -1 = error */ + int last_build_rc; /* from index; -1 = never built */ + time_t last_build_ts; /* from index; 0 = never built */ +} StatusResult; + +/* + * Scan every subdirectory of clone_dir that contains .git. + * For each one, fetch the three git metrics and join them with build + * metadata from idx. Results are written into results[0..max-1]. + * Worker threads run the git queries in parallel (pool of SCAN_THREADS). + * Returns the number of projects found (may be 0, capped at max). + */ +#define SCAN_THREADS 6 +int project_scan_all(const char *clone_dir, const ProjectIndex *idx, + StatusResult *results, int max); + +#endif /* INDEX_H */ diff --git a/include/tui.h b/include/tui.h index a2c035f..ca6cd1d 100644 --- a/include/tui.h +++ b/include/tui.h @@ -13,4 +13,17 @@ int tui_pick_target(const MakeTargets *targets, char *selected, size_t sel_size * Keys: ↑↓ navigate Enter open in less d delete q quit */ void tui_log_browser(const char *log_dir); +#include "index.h" + +/* Full-screen project overview. + * Lists all projects in idx with last-build status, timestamp and HEAD hash. + * On Enter, fills selected_project and returns the row index. + * Returns -1 if the user quit without selecting. + * Keys: ↑↓ navigate Enter build l log-browser q/ESC quit */ +int tui_project_overview(ProjectIndex *idx, + const char *clone_dir, + const char *log_dir, + char *selected_project, + size_t sel_size); + #endif /* TUI_H */ diff --git a/src/cache.c b/src/cache.c new file mode 100644 index 0000000..d970de2 --- /dev/null +++ b/src/cache.c @@ -0,0 +1,65 @@ +#include "cache.h" + +#include +#include +#include + +/* Name of the dotfile kept inside each cloned repo. */ +#define CACHE_FILENAME ".gbuild_cache" + +/* ----------------------------------------------------------------- helpers */ + +static void cache_path(char *out, size_t n, const char *repo_path) +{ + snprintf(out, n, "%s/" CACHE_FILENAME, repo_path); +} + +/* ----------------------------------------------------------------- public API */ + +int cache_read(const char *repo_path, char *out, size_t n) +{ + char path[1024]; + cache_path(path, sizeof(path), repo_path); + + out[0] = '\0'; + + FILE *f = fopen(path, "r"); + if (!f) + return -1; /* file doesn't exist yet — not an error, just a cache miss */ + + char buf[CACHE_HASH_LEN] = {0}; + int ok = (fgets(buf, sizeof(buf), f) != NULL); + fclose(f); + + if (!ok || buf[0] == '\0') + return -1; + + /* strip trailing newline */ + size_t l = strlen(buf); + if (l > 0 && buf[l - 1] == '\n') + buf[--l] = '\0'; + + if (l == 0) + return -1; + + strncpy(out, buf, n - 1); + out[n - 1] = '\0'; + return 0; +} + +int cache_write(const char *repo_path, const char *hash) +{ + char path[1024]; + cache_path(path, sizeof(path), repo_path); + + FILE *f = fopen(path, "w"); + if (!f) { + fprintf(stderr, "[WARN ] cache_write: cannot open %s: %s\n", + path, strerror(errno)); + return -1; + } + + fprintf(f, "%s\n", hash); + fclose(f); + return 0; +} diff --git a/src/config.c b/src/config.c index a89ba18..6fc02af 100644 --- a/src/config.c +++ b/src/config.c @@ -1,12 +1,3 @@ -/* - -This file is licensed under the MIT license. - -Copyright (c) Schmidt Peter Daniel 2026 - - -*/ - #include "config.h" #include @@ -51,6 +42,9 @@ void config_defaults(GConfig *cfg) if (!home) home = "/tmp"; snprintf(cfg->clone_dir, CFG_MAX, "%s/projects", home); snprintf(cfg->log_dir, CFG_MAX, "%s/.local/log/gbuild", home); + + cfg->post_build_hook[0] = '\0'; + cfg->project_override_count = 0; } /* ------------------------------------------------------------------ loader */ @@ -76,13 +70,13 @@ int config_load(GConfig *cfg, const char *path) if (!f) return -1; char line[512]; - char section[64] = ""; + char section[128] = ""; /* e.g. "build", "project:myproject" */ while (fgets(line, sizeof(line), f)) { strip(line); if (line[0] == '\0' || line[0] == '#') continue; - /* section header */ + /* ---- section header ---- */ if (line[0] == '[') { char *end = strchr(line, ']'); if (end) { @@ -92,7 +86,7 @@ int config_load(GConfig *cfg, const char *path) continue; } - /* key = value */ + /* ---- key = value ---- */ char *eq = strchr(line, '='); if (!eq) continue; *eq = '\0'; @@ -104,6 +98,30 @@ int config_load(GConfig *cfg, const char *path) char expanded[CFG_MAX]; expand_tilde(val, expanded, sizeof(expanded)); + /* ---- [project:] overrides ---- */ + if (strncmp(section, "project:", 8) == 0) { + const char *proj_name = section + 8; + if (proj_name[0] == '\0') continue; + + /* find or create an override slot for this project */ + GProjectOverride *ov = NULL; + for (int i = 0; i < cfg->project_override_count; i++) { + if (!strcmp(cfg->project_overrides[i].name, proj_name)) { + ov = &cfg->project_overrides[i]; + break; + } + } + if (!ov && cfg->project_override_count < CFG_MAX_PROJECT_OVERRIDES) { + ov = &cfg->project_overrides[cfg->project_override_count++]; + memset(ov, 0, sizeof(*ov)); + strncpy(ov->name, proj_name, CFG_PROJECT_NAME_LEN - 1); + } + if (ov && !strcmp(key, "POST_BUILD_HOOK")) + strncpy(ov->post_build_hook, expanded, CFG_MAX - 1); + continue; + } + + /* ---- global keys ---- */ if (!strcmp(key, "GIT_URL")) strncpy(cfg->git_url, expanded, CFG_MAX - 1); else if (!strcmp(key, "GIT_USER")) strncpy(cfg->git_user, expanded, sizeof(cfg->git_user) - 1); else if (!strcmp(key, "GIT_TOKEN")) strncpy(cfg->git_token, expanded, sizeof(cfg->git_token) - 1); @@ -113,7 +131,7 @@ int config_load(GConfig *cfg, const char *path) else if (!strcmp(key, "LOG_DIR")) strncpy(cfg->log_dir, expanded, CFG_MAX - 1); else if (!strcmp(key, "LOG_ENABLED")) cfg->log_enabled = (strcmp(expanded, "false") != 0 && strcmp(expanded, "0") != 0); - (void)section; + else if (!strcmp(key, "POST_BUILD_HOOK")) strncpy(cfg->post_build_hook, expanded, CFG_MAX - 1); } fclose(f); @@ -137,8 +155,11 @@ int config_save(const GConfig *cfg, const char *path) "GIT_TOKEN = %s\n" "GIT_PASSWORD = %s\n\n" "[build]\n" - "DEFAULT_TARGET = %s\n" - "CLONE_DIR = %s\n\n" + "DEFAULT_TARGET = %s\n" + "CLONE_DIR = %s\n" + "# POST_BUILD_HOOK runs after every successful build (leave blank for none)\n" + "# Example: POST_BUILD_HOOK = systemctl restart myservice\n" + "POST_BUILD_HOOK = %s\n\n" "[log]\n" "LOG_ENABLED = %s\n" "LOG_DIR = %s\n", @@ -148,9 +169,21 @@ int config_save(const GConfig *cfg, const char *path) cfg->git_password, cfg->default_target, cfg->clone_dir, + cfg->post_build_hook, cfg->log_enabled ? "true" : "false", cfg->log_dir); + /* Per-project overrides: only write sections that have a hook set */ + for (int i = 0; i < cfg->project_override_count; i++) { + const GProjectOverride *ov = &cfg->project_overrides[i]; + if (ov->post_build_hook[0] == '\0') continue; + fprintf(f, + "\n[project:%s]\n" + "POST_BUILD_HOOK = %s\n", + ov->name, + ov->post_build_hook); + } + fclose(f); return 0; } @@ -195,10 +228,37 @@ void config_show(const GConfig *cfg) printf(" GIT_PASSWORD = %s\n", pass); printf("\n [build]\n"); - printf(" DEFAULT_TARGET = %s\n", cfg->default_target[0] ? cfg->default_target : "(not set)"); - printf(" CLONE_DIR = %s\n", cfg->clone_dir); + printf(" DEFAULT_TARGET = %s\n", cfg->default_target[0] ? cfg->default_target : "(not set)"); + printf(" CLONE_DIR = %s\n", cfg->clone_dir); + printf(" POST_BUILD_HOOK = %s\n", cfg->post_build_hook[0] ? cfg->post_build_hook : "(not set)"); printf("\n [log]\n"); printf(" LOG_ENABLED = %s\n", cfg->log_enabled ? "true" : "false"); - printf(" LOG_DIR = %s\n\n", cfg->log_dir); + printf(" LOG_DIR = %s\n", cfg->log_dir); + + if (cfg->project_override_count > 0) { + printf("\n Per-project hook overrides:\n"); + for (int i = 0; i < cfg->project_override_count; i++) { + const GProjectOverride *ov = &cfg->project_overrides[i]; + if (ov->post_build_hook[0] == '\0') continue; + printf(" [project:%s]\n", ov->name); + printf(" POST_BUILD_HOOK = %s\n", ov->post_build_hook); + } + } + + printf("\n"); +} + +/* ------------------------------------------------------------------ hook lookup */ + +const char *config_hook_for(const GConfig *cfg, const char *project) +{ + /* Check per-project overrides first */ + for (int i = 0; i < cfg->project_override_count; i++) { + const GProjectOverride *ov = &cfg->project_overrides[i]; + if (!strcmp(ov->name, project) && ov->post_build_hook[0] != '\0') + return ov->post_build_hook; + } + /* Fall back to global hook (may also be empty string = no hook) */ + return cfg->post_build_hook; } diff --git a/src/gbuild.c b/src/gbuild.c index 9bc8b8a..74588c6 100644 --- a/src/gbuild.c +++ b/src/gbuild.c @@ -1,5 +1,8 @@ +#include "cache.h" #include "config.h" +#include "hooks.h" #include "git_ops.h" +#include "index.h" #include "logger.h" #include "make_ops.h" #include "tui.h" @@ -8,6 +11,7 @@ #include #include #include +#include #include #define VERSION "1.0.0" @@ -20,20 +24,272 @@ static void print_usage(const char *prog) "gbuild %s — A build tool for your Linux distro\n\n" "Usage:\n" " %s [options] \n" + " %s (no args — opens project overview TUI)\n" " %s --logs\n\n" "Options:\n" - " --url Override Git base URL (default: ~/.gconfig)\n" - " --user Override Git username (default: ~/.gconfig)\n" - " --target Run target directly, skip interactive picker\n" + " --url Override Git base URL (default: ~/.gconfig)\n" + " --user Override Git username (default: ~/.gconfig)\n" + " --target Run target directly, skip interactive picker\n" + " --force Force build even if HEAD hash is unchanged\n" + " --status Print status table for all cloned projects\n" + " --no-color Disable ANSI colour in --status output\n" " --logs Open the interactive log browser\n" + " --no-tui With no , print usage instead of opening TUI\n" " -h, --help Print this help and exit\n\n" "Examples:\n" + " gbuild (overview TUI — pick and build)\n" " gbuild myproject\n" + " gbuild --force myproject\n" + " gbuild --status\n" + " gbuild --status --no-color | grep dirty\n" " gbuild --target clean myproject\n" " gbuild --url http://10.0.0.5:3000 --user alice myproject\n" " gbuild --logs\n\n" "Configuration is read from ~/.gconfig. Run 'gconfig init' to set it up.\n", - VERSION, prog, prog); + VERSION, prog, prog, prog); +} + +/* ----------------------------------------------------------------- status table */ + +/* ANSI colour codes (empty strings when --no-color) */ +#define COL_RED "\033[31m" +#define COL_GREEN "\033[32m" +#define COL_YELLOW "\033[33m" +#define COL_BOLD "\033[1m" +#define COL_RESET "\033[0m" + +static void print_status_table(const char *clone_dir, + const ProjectIndex *idx, + int no_color) +{ + StatusResult results[IDX_MAX_PROJECTS]; + int n = project_scan_all(clone_dir, idx, results, IDX_MAX_PROJECTS); + + if (n == 0) { + printf("No git repositories found in %s\n", clone_dir); + return; + } + + const char *c_red = no_color ? "" : COL_RED; + const char *c_green = no_color ? "" : COL_GREEN; + const char *c_yellow = no_color ? "" : COL_YELLOW; + const char *c_bold = no_color ? "" : COL_BOLD; + const char *c_reset = no_color ? "" : COL_RESET; + + /* Header */ + printf("%s%-24s %-20s %-7s %-5s %-14s %s%s\n", + c_bold, + "PROJECT", "BRANCH", "BEHIND", "DIRTY", "LAST BUILD", "BUILD RC", + c_reset); + printf("%-24s %-20s %-7s %-5s %-14s %s\n", + "------------------------", + "--------------------", + "-------", + "-----", + "--------------", + "--------"); + + for (int i = 0; i < n; i++) { + StatusResult *r = &results[i]; + + /* ---- colour for project name / branch ---- */ + const char *nc = c_green; /* default: clean */ + if (r->dirty == 1 || r->behind > 0) + nc = c_yellow; + + /* ---- behind field ---- */ + char behind_s[64]; + if (r->behind < 0) + snprintf(behind_s, sizeof(behind_s), "n/a"); + else if (r->behind == 0) + snprintf(behind_s, sizeof(behind_s), "0"); + else + snprintf(behind_s, sizeof(behind_s), "%s%d%s", + c_yellow, r->behind, c_reset); + + /* ---- dirty field ---- */ + const char *dirty_s; + if (r->dirty < 0) + dirty_s = "n/a"; + else if (r->dirty == 0) + dirty_s = "no"; + else + dirty_s = no_color ? "YES" : COL_YELLOW "yes" COL_RESET; + + /* ---- build status ---- */ + char ts_s[20] = "never"; + if (r->last_build_ts > 0) { + struct tm *tm = localtime(&r->last_build_ts); + strftime(ts_s, sizeof(ts_s), "%Y-%m-%d %H:%M", tm); + } + + const char *bc; + char rc_s[64]; + if (r->last_build_rc < 0) { + bc = c_red; + snprintf(rc_s, sizeof(rc_s), "never"); + } else if (r->last_build_rc == 0) { + bc = c_green; + snprintf(rc_s, sizeof(rc_s), "ok (0)"); + } else { + bc = c_red; + snprintf(rc_s, sizeof(rc_s), "fail (%d)", r->last_build_rc); + } + + /* Truncate branch if too long */ + char branch_disp[21]; + snprintf(branch_disp, sizeof(branch_disp), "%s", r->branch); + + printf("%s%-24s%s %s%-20s%s %-7s %-5s %-14s %s%s%s\n", + nc, r->name, c_reset, + nc, branch_disp, c_reset, + behind_s, + dirty_s, + ts_s, + bc, rc_s, c_reset); + } +} + +/* ----------------------------------------------------------------- build one project */ + +static int build_project(const char *project, + const char *arg_target, + GConfig *cfg, + const char *clone_dir, + const char *log_dir, + ProjectIndex *idx, + int force) +{ + /* ---- open logger ---- */ + Logger log; + memset(&log, 0, sizeof(log)); + if (cfg->log_enabled) { + if (logger_open(&log, log_dir, project) == 0) + logger_info(&log, "Logging to: %s", log.path); + else + fprintf(stderr, "[WARN ] Could not open log file in %s\n", log_dir); + } + + logger_info(&log, "gbuild started for project: %s", project); + + /* ---- clone or pull ---- */ + char repo_path[CFG_MAX * 2]; + snprintf(repo_path, sizeof(repo_path), "%s/%s", clone_dir, project); + + if (git_repo_exists(clone_dir, project)) { + logger_info(&log, "Repo already cloned — pulling latest changes ..."); + int rc = git_pull(repo_path, &log); + if (rc != 0) { + logger_error(&log, "git pull failed (exit %d).", rc); + logger_close(&log); + return 1; + } + logger_ok(&log, "Repository updated."); + } else { + logger_info(&log, "Cloning %s/%s/%s ...", + cfg->git_url, cfg->git_user, project); + int rc = git_clone(cfg->git_url, cfg->git_user, + cfg->git_token, cfg->git_password, + project, clone_dir, &log); + if (rc != 0) { + logger_error(&log, "git clone failed (exit %d).", rc); + logger_close(&log); + return 1; + } + logger_ok(&log, "Repository cloned."); + } + + /* ---- read current HEAD hash ---- */ + char current_hash[IDX_HASH_LEN] = {0}; + int have_hash = (git_head_hash(repo_path, current_hash, sizeof(current_hash)) == 0); + + /* ---- dirty detection: skip build if HEAD unchanged ---- */ + if (!force && have_hash) { + char cached_hash[CACHE_HASH_LEN] = {0}; + if (cache_read(repo_path, cached_hash, sizeof(cached_hash)) == 0 && + cached_hash[0] != '\0' && + strncmp(current_hash, cached_hash, IDX_HASH_LEN - 1) == 0) + { + logger_ok(&log, + "Build skipped — repo is unchanged since last successful build " + "(HEAD: %.12s). Use --force to build anyway.", + current_hash); + logger_close(&log); + return 0; + } + } + + /* ---- record HEAD hash in index ---- */ + ProjectRecord *rec = index_upsert(idx, project); + if (rec && have_hash) + strncpy(rec->last_head_hash, current_hash, IDX_HASH_LEN - 1); + + /* ---- resolve make target ---- */ + char target[TARGET_NAME]; + memset(target, 0, sizeof(target)); + + if (arg_target) { + strncpy(target, arg_target, sizeof(target) - 1); + logger_info(&log, "Selected target: %s", target); + } else if (cfg->default_target[0]) { + strncpy(target, cfg->default_target, sizeof(target) - 1); + logger_info(&log, "Using default target: %s", target); + } else { + MakeTargets targets; + if (make_parse_targets(repo_path, &targets) != 0) { + logger_warn(&log, + "No targets found in Makefile — running 'make' with no target."); + target[0] = '\0'; + } else { + if (tui_pick_target(&targets, target, sizeof(target)) < 0) { + logger_warn(&log, "No target selected — aborting."); + logger_close(&log); + return 0; + } + } + logger_info(&log, "Selected target: %s", + target[0] ? target : "(default)"); + } + + /* ---- build ---- */ + logger_info(&log, "Building '%s' target '%s' ...", + project, target[0] ? target : "(default)"); + + int rc = make_build(repo_path, target, &log); + + /* ---- update index ---- */ + if (rec) { + rec->last_build_rc = rc; + rec->last_build_ts = time(NULL); + } + + if (rc != 0) { + logger_error(&log, "Build failed (exit %d).", rc); + logger_close(&log); + return 1; + } + + /* ---- write cache only on success ---- */ + if (have_hash) + cache_write(repo_path, current_hash); + + /* ---- post-build hook ---- */ + const char *hook = config_hook_for(cfg, project); + if (hook && hook[0] != '\0') { + int hook_rc = hook_run(hook, repo_path, &log); + if (hook_rc != 0) { + logger_error(&log, + "Post-build hook exited with status %d " + "(build itself succeeded).", hook_rc); + logger_close(&log); + return 2; /* 2 = hook failure, distinct from build failure (1) */ + } + logger_ok(&log, "Post-build hook completed successfully."); + } + + logger_ok(&log, "gbuild complete."); + logger_close(&log); + return 0; } /* ----------------------------------------------------------------- main */ @@ -45,7 +301,11 @@ int main(int argc, char *argv[]) const char *arg_user = NULL; const char *arg_target = NULL; const char *arg_project = NULL; - int flag_logs = 0; + int flag_logs = 0; + int flag_no_tui = 0; + int flag_force = 0; + int flag_status = 0; + int flag_no_color = 0; for (int i = 1; i < argc; i++) { if (!strcmp(argv[i], "-h") || !strcmp(argv[i], "--help")) { @@ -60,8 +320,16 @@ int main(int argc, char *argv[]) } else if (!strcmp(argv[i], "--target")) { if (++i >= argc) { fprintf(stderr, "error: --target requires a value\n"); return 1; } arg_target = argv[i]; + } else if (!strcmp(argv[i], "--force")) { + flag_force = 1; + } else if (!strcmp(argv[i], "--status")) { + flag_status = 1; + } else if (!strcmp(argv[i], "--no-color")) { + flag_no_color = 1; } else if (!strcmp(argv[i], "--logs")) { flag_logs = 1; + } else if (!strcmp(argv[i], "--no-tui")) { + flag_no_tui = 1; } else if (argv[i][0] == '-') { fprintf(stderr, "error: unknown flag '%s'\n", argv[i]); return 1; @@ -86,109 +354,60 @@ int main(int argc, char *argv[]) config_defaults(&cfg); } - /* apply overrides */ if (arg_url) strncpy(cfg.git_url, arg_url, CFG_MAX - 1); if (arg_user) strncpy(cfg.git_user, arg_user, sizeof(cfg.git_user) - 1); - /* expand paths that might contain ~ */ char clone_dir[CFG_MAX], log_dir[CFG_MAX]; expand_tilde(cfg.clone_dir, clone_dir, sizeof(clone_dir)); expand_tilde(cfg.log_dir, log_dir, sizeof(log_dir)); + /* ---- load / scan project index ---- */ + ProjectIndex idx; + char idx_path[512]; + index_get_path(idx_path, sizeof(idx_path)); + index_load(&idx, idx_path); /* ok if file doesn't exist yet */ + index_scan(&idx, clone_dir); /* pick up any new repos on disk */ + + /* ---- status table mode ---- */ + if (flag_status) { + print_status_table(clone_dir, &idx, flag_no_color); + return 0; + } + /* ---- log browser mode ---- */ if (flag_logs) { tui_log_browser(log_dir); return 0; } - /* ---- need a project name ---- */ + /* ---- no project given: open overview TUI or print usage ---- */ if (!arg_project) { - fprintf(stderr, "error: no project specified.\n\n"); - print_usage(argv[0]); - return 1; - } - - /* ---- open logger ---- */ - Logger log; - memset(&log, 0, sizeof(log)); - if (cfg.log_enabled) { - if (logger_open(&log, log_dir, arg_project) == 0) - logger_info(&log, "Logging to: %s", log.path); - else - fprintf(stderr, "[WARN ] Could not open log file in %s\n", log_dir); - } - - logger_info(&log, "gbuild started for project: %s", arg_project); - - /* ---- clone or pull ---- */ - char repo_path[CFG_MAX * 2]; - snprintf(repo_path, sizeof(repo_path), "%s/%s", clone_dir, arg_project); - - if (git_repo_exists(clone_dir, arg_project)) { - logger_info(&log, "Repo already cloned — pulling latest changes ..."); - int rc = git_pull(repo_path, &log); - if (rc != 0) { - logger_error(&log, "git pull failed (exit %d).", rc); - logger_close(&log); - return 1; + if (flag_no_tui || idx.count == 0) { + if (idx.count == 0 && !flag_no_tui) + fprintf(stderr, + "No projects found in %s.\n" + "Run 'gbuild ' to clone and build one first.\n", + clone_dir); + else + print_usage(argv[0]); + return (idx.count == 0) ? 1 : 0; } - logger_ok(&log, "Repository updated."); - } else { - logger_info(&log, "Cloning %s/%s/%s ...", - cfg.git_url, cfg.git_user, arg_project); - int rc = git_clone(cfg.git_url, cfg.git_user, - cfg.git_token, cfg.git_password, - arg_project, clone_dir, &log); - if (rc != 0) { - logger_error(&log, "git clone failed (exit %d).", rc); - logger_close(&log); - return 1; - } - logger_ok(&log, "Repository cloned."); - } - /* ---- resolve make target ---- */ - char target[TARGET_NAME]; - memset(target, 0, sizeof(target)); + static char chosen[IDX_NAME_LEN]; + memset(chosen, 0, sizeof(chosen)); + if (tui_project_overview(&idx, clone_dir, log_dir, + chosen, sizeof(chosen)) < 0) + return 0; /* user quit without picking */ - if (arg_target) { - /* explicit --target flag */ - strncpy(target, arg_target, sizeof(target) - 1); - logger_info(&log, "Selected target: %s", target); - } else if (cfg.default_target[0]) { - /* default from config */ - strncpy(target, cfg.default_target, sizeof(target) - 1); - logger_info(&log, "Using default target: %s", target); - } else { - /* interactive TUI picker */ - MakeTargets targets; - if (make_parse_targets(repo_path, &targets) != 0) { - logger_warn(&log, - "No targets found in Makefile — running 'make' with no target."); - target[0] = '\0'; - } else { - if (tui_pick_target(&targets, target, sizeof(target)) < 0) { - logger_warn(&log, "No target selected — aborting."); - logger_close(&log); - return 0; - } - } - logger_info(&log, "Selected target: %s", - target[0] ? target : "(default)"); + arg_project = chosen; } /* ---- build ---- */ - logger_info(&log, "gbuild: building '%s' target '%s' ...", - arg_project, target[0] ? target : "(default)"); + int rc = build_project(arg_project, arg_target, + &cfg, clone_dir, log_dir, &idx, flag_force); - int rc = make_build(repo_path, target, &log); - if (rc != 0) { - logger_error(&log, "Build failed (exit %d).", rc); - logger_close(&log); - return 1; - } + /* ---- persist updated index ---- */ + index_save(&idx, idx_path); - logger_ok(&log, "gbuild complete."); - logger_close(&log); - return 0; + return rc; } diff --git a/src/git_ops.c b/src/git_ops.c index 69588eb..efb1cd2 100644 --- a/src/git_ops.c +++ b/src/git_ops.c @@ -1,13 +1,6 @@ -/* - -This file is licensed under the MIT license. -Copyright (c) Schmidt Peter Daniel 2026. - - -*/ - #include "git_ops.h" #include "config.h" +#include "index.h" #include #include @@ -131,3 +124,112 @@ int git_pull(const char *repo_path, Logger *log) "git -C \"%s\" pull 2>&1", repo_path); return run_stream(cmd, log); } + +int git_head_hash(const char *repo_path, char *out, size_t n) +{ + char cmd[IDX_PATH_LEN + 64]; + snprintf(cmd, sizeof(cmd), + "git -C \"%s\" rev-parse HEAD 2>/dev/null", repo_path); + + FILE *p = popen(cmd, "r"); + if (!p) return -1; + + char buf[64] = {0}; + int ok = (fgets(buf, sizeof(buf), p) != NULL); + pclose(p); + + if (!ok || buf[0] == '\0') return -1; + + /* strip newline */ + size_t l = strlen(buf); + if (l > 0 && buf[l-1] == '\n') buf[--l] = '\0'; + + strncpy(out, buf, n - 1); + out[n - 1] = '\0'; + return 0; +} + +/* --------------------------------------------------------- status helpers */ + +/* + * Helper: run a git command silently (stderr→/dev/null), capture first line + * of stdout into buf. Returns 0 on success, -1 on error / non-zero exit. + */ +static int git_capture(const char *cmd, char *buf, size_t n) +{ + FILE *p = popen(cmd, "r"); + if (!p) return -1; + + char tmp[512] = {0}; + int got = (fgets(tmp, sizeof(tmp), p) != NULL); + int st = pclose(p); + + if (!got || WEXITSTATUS(st) != 0) return -1; + + /* strip newline */ + size_t l = strlen(tmp); + if (l > 0 && tmp[l-1] == '\n') tmp[--l] = '\0'; + + strncpy(buf, tmp, n - 1); + buf[n - 1] = '\0'; + return 0; +} + +int git_current_branch(const char *repo_path, char *out, size_t n) +{ + char cmd[IDX_PATH_LEN + 64]; + snprintf(cmd, sizeof(cmd), + "git -C \"%s\" branch --show-current 2>/dev/null", repo_path); + + if (git_capture(cmd, out, n) != 0 || out[0] == '\0') { + /* detached HEAD — show the short hash instead */ + snprintf(cmd, sizeof(cmd), + "git -C \"%s\" rev-parse --short HEAD 2>/dev/null", repo_path); + if (git_capture(cmd, out, n) != 0) + strncpy(out, "(unknown)", n - 1); + else { + /* prepend marker so caller knows it's detached */ + char tmp[IDX_PATH_LEN]; + snprintf(tmp, sizeof(tmp), "(detached:%s)", out); + strncpy(out, tmp, n - 1); + } + out[n - 1] = '\0'; + } + return 0; +} + +int git_is_dirty(const char *repo_path) +{ + char cmd[IDX_PATH_LEN + 64]; + snprintf(cmd, sizeof(cmd), + "git -C \"%s\" status --porcelain 2>/dev/null", repo_path); + + FILE *p = popen(cmd, "r"); + if (!p) return -1; + + /* Any output at all means dirty */ + char buf[4]; + int dirty = (fgets(buf, sizeof(buf), p) != NULL); + pclose(p); + return dirty; +} + +int git_behind_count(const char *repo_path) +{ + /* Fetch quietly first; ignore failure (offline is not an error) */ + char cmd[IDX_PATH_LEN + 128]; + snprintf(cmd, sizeof(cmd), + "git -C \"%s\" fetch --quiet 2>/dev/null", repo_path); + (void)system(cmd); + + /* Count commits reachable from upstream but not from HEAD */ + snprintf(cmd, sizeof(cmd), + "git -C \"%s\" rev-list HEAD..@{u} --count 2>/dev/null", + repo_path); + + char buf[32] = {0}; + if (git_capture(cmd, buf, sizeof(buf)) != 0) + return -1; /* no upstream configured, or other error */ + + return atoi(buf); +} diff --git a/src/hooks.c b/src/hooks.c new file mode 100644 index 0000000..bcdffe0 --- /dev/null +++ b/src/hooks.c @@ -0,0 +1,82 @@ +#include "hooks.h" + +#include +#include +#include +#include +#include +#include + +/* ----------------------------------------------------------------- public API */ + +/* + * Run cmd via the user's shell (sh -c) with CWD set to working_dir. + * Every line of the hook's combined stdout+stderr is forwarded to log. + * + * Implementation notes: + * - We use popen() with a wrapper that sets the CWD via "cd ... && cmd" + * so that we can stream output without a manual fork/pipe/exec. + * - "cd" failures are caught: if the directory does not exist, the shell + * will emit an error and exit non-zero, which we propagate normally. + * - Single-quotes in working_dir are escaped so an unusual path cannot + * break out of the cd argument. + */ +int hook_run(const char *cmd, const char *working_dir, Logger *log) +{ + if (!cmd || cmd[0] == '\0') + return 0; + + /* + * Build: cd '' && + * + * Escape single-quotes in working_dir by replacing each ' with '\'' + * (close quote, literal single-quote, reopen quote). This is safe + * regardless of what characters appear in the path. + */ + char safe_dir[4096] = {0}; + { + const char *s = working_dir ? working_dir : "."; + size_t di = 0; + for (size_t si = 0; s[si] && di + 5 < sizeof(safe_dir); si++) { + if (s[si] == '\'') { + /* ' → '\'' */ + safe_dir[di++] = '\''; + safe_dir[di++] = '\\'; + safe_dir[di++] = '\''; + safe_dir[di++] = '\''; + } else { + safe_dir[di++] = s[si]; + } + } + safe_dir[di] = '\0'; + } + + /* Total command: cd '

' && , redirect stderr into stdout */ + char shell_cmd[8192]; + snprintf(shell_cmd, sizeof(shell_cmd), + "cd '%s' && %s 2>&1", safe_dir, cmd); + + logger_info(log, "Running post-build hook: %s", cmd); + + FILE *pipe = popen(shell_cmd, "r"); + if (!pipe) { + logger_error(log, "hook_run: popen failed: %s", strerror(errno)); + return -1; + } + + char line[1024]; + while (fgets(line, sizeof(line), pipe)) { + /* strip trailing newline so logger_raw adds its own */ + size_t l = strlen(line); + if (l > 0 && line[l - 1] == '\n') line[--l] = '\0'; + logger_raw(log, "%s\n", line); + } + + int status = pclose(pipe); + if (status == -1) { + logger_error(log, "hook_run: pclose failed: %s", strerror(errno)); + return -1; + } + + return WEXITSTATUS(status); +} diff --git a/src/index.c b/src/index.c new file mode 100644 index 0000000..fb60b4b --- /dev/null +++ b/src/index.c @@ -0,0 +1,277 @@ +#include "index.h" + +#include +#include +#include +#include +#include +#include + +/* ---------------------------------------------------------------- path */ + +void index_get_path(char *out, size_t n) +{ + const char *home = getenv("HOME"); + if (!home) home = "/tmp"; + snprintf(out, n, "%s/.gbuild_index", home); +} + +/* ---------------------------------------------------------------- helpers */ + +static void strip(char *s) +{ + char *p = s; + while (*p == ' ' || *p == '\t') p++; + if (p != s) memmove(s, p, strlen(p) + 1); + size_t l = strlen(s); + while (l > 0 && (s[l-1] == '\n' || s[l-1] == '\r' || + s[l-1] == ' ' || s[l-1] == '\t')) + s[--l] = '\0'; +} + +/* ---------------------------------------------------------------- load */ + +int index_load(ProjectIndex *idx, const char *path) +{ + memset(idx, 0, sizeof(*idx)); + /* pre-set last_build_rc to -1 (never built) for all slots */ + for (int i = 0; i < IDX_MAX_PROJECTS; i++) + idx->projects[i].last_build_rc = -1; + + FILE *f = fopen(path, "r"); + if (!f) return -1; + + char line[512]; + char section[IDX_NAME_LEN] = ""; + ProjectRecord *cur = NULL; + + while (fgets(line, sizeof(line), f)) { + strip(line); + if (line[0] == '\0' || line[0] == '#') continue; + + if (line[0] == '[') { + char *end = strchr(line, ']'); + if (!end) continue; + *end = '\0'; + strncpy(section, line + 1, sizeof(section) - 1); + cur = index_upsert(idx, section); + continue; + } + + if (!cur) continue; + + char *eq = strchr(line, '='); + if (!eq) continue; + *eq = '\0'; + char *key = line; + char *val = eq + 1; + strip(key); + strip(val); + + if (!strcmp(key, "last_build_rc")) + cur->last_build_rc = atoi(val); + else if (!strcmp(key, "last_build_ts")) + cur->last_build_ts = (time_t)atol(val); + else if (!strcmp(key, "last_head_hash")) + strncpy(cur->last_head_hash, val, IDX_HASH_LEN - 1); + } + + fclose(f); + return 0; +} + +/* ---------------------------------------------------------------- save */ + +int index_save(const ProjectIndex *idx, const char *path) +{ + FILE *f = fopen(path, "w"); + if (!f) return -1; + + fprintf(f, "# ~/.gbuild_index — managed by gbuild\n\n"); + + for (int i = 0; i < idx->count; i++) { + const ProjectRecord *r = &idx->projects[i]; + fprintf(f, "[%s]\n", r->name); + fprintf(f, "last_build_rc = %d\n", r->last_build_rc); + fprintf(f, "last_build_ts = %ld\n", (long)r->last_build_ts); + fprintf(f, "last_head_hash = %s\n", r->last_head_hash); + fprintf(f, "\n"); + } + + fclose(f); + return 0; +} + +/* ---------------------------------------------------------------- find / upsert */ + +ProjectRecord *index_find(ProjectIndex *idx, const char *name) +{ + for (int i = 0; i < idx->count; i++) + if (!strcmp(idx->projects[i].name, name)) + return &idx->projects[i]; + return NULL; +} + +ProjectRecord *index_upsert(ProjectIndex *idx, const char *name) +{ + ProjectRecord *r = index_find(idx, name); + if (r) return r; + + if (idx->count >= IDX_MAX_PROJECTS) return NULL; + + r = &idx->projects[idx->count++]; + memset(r, 0, sizeof(*r)); + strncpy(r->name, name, IDX_NAME_LEN - 1); + r->last_build_rc = -1; /* never built */ + return r; +} + +/* ---------------------------------------------------------------- scan */ + +int index_scan(ProjectIndex *idx, const char *clone_dir) +{ + DIR *d = opendir(clone_dir); + if (!d) return 0; + + int added = 0; + struct dirent *de; + + while ((de = readdir(d))) { + if (de->d_name[0] == '.') continue; + + /* check for a .git subdir */ + char git_path[IDX_PATH_LEN]; + snprintf(git_path, sizeof(git_path), + "%s/%s/.git", clone_dir, de->d_name); + + struct stat st; + if (stat(git_path, &st) != 0 || !S_ISDIR(st.st_mode)) continue; + + /* only add if not already tracked — don't clobber build metadata */ + if (!index_find(idx, de->d_name)) { + if (index_upsert(idx, de->d_name)) + added++; + } + } + + closedir(d); + return added; +} + +/* ---------------------------------------------------------------- status scan */ + +#include "git_ops.h" + +#include + +/* Work item passed to each worker thread */ +typedef struct { + char repo_path[IDX_PATH_LEN]; + char name[IDX_NAME_LEN]; + StatusResult result; + /* build metadata copied in from index before dispatch */ + int last_build_rc; + time_t last_build_ts; +} ScanWork; + +static void *scan_worker(void *arg) +{ + ScanWork *w = (ScanWork *)arg; + + git_current_branch(w->repo_path, w->result.branch, sizeof(w->result.branch)); + w->result.dirty = git_is_dirty(w->repo_path); + w->result.behind = git_behind_count(w->repo_path); + + strncpy(w->result.name, w->name, IDX_NAME_LEN - 1); + w->result.last_build_rc = w->last_build_rc; + w->result.last_build_ts = w->last_build_ts; + + return NULL; +} + +int project_scan_all(const char *clone_dir, const ProjectIndex *idx, + StatusResult *results, int max) +{ + /* Collect all project names first */ + char names[IDX_MAX_PROJECTS][IDX_NAME_LEN]; + int total = 0; + + DIR *d = opendir(clone_dir); + if (!d) return 0; + + struct dirent *de; + while ((de = readdir(d)) && total < IDX_MAX_PROJECTS) { + if (de->d_name[0] == '.') continue; + + char git_path[IDX_PATH_LEN]; + snprintf(git_path, sizeof(git_path), + "%s/%s/.git", clone_dir, de->d_name); + + struct stat st; + if (stat(git_path, &st) != 0 || !S_ISDIR(st.st_mode)) continue; + + strncpy(names[total], de->d_name, IDX_NAME_LEN - 1); + names[total][IDX_NAME_LEN - 1] = '\0'; + total++; + } + closedir(d); + + if (total == 0) return 0; + if (total > max) total = max; + + /* Allocate one ScanWork per project */ + ScanWork *work = calloc((size_t)total, sizeof(ScanWork)); + if (!work) return 0; + + /* Fill work items with repo path + index metadata */ + for (int i = 0; i < total; i++) { + strncpy(work[i].name, names[i], IDX_NAME_LEN - 1); + snprintf(work[i].repo_path, IDX_PATH_LEN, + "%s/%s", clone_dir, names[i]); + + /* defaults when not in index */ + work[i].last_build_rc = -1; + work[i].last_build_ts = 0; + + /* look up build history */ + for (int j = 0; j < idx->count; j++) { + if (!strcmp(idx->projects[j].name, names[i])) { + work[i].last_build_rc = idx->projects[j].last_build_rc; + work[i].last_build_ts = idx->projects[j].last_build_ts; + break; + } + } + } + + /* + * Worker-pool: keep SCAN_THREADS threads running at a time. + * We use a sliding window: launch threads 0..pool-1, then join + * thread 0 before launching thread pool, etc. + */ + int pool = (total < SCAN_THREADS) ? total : SCAN_THREADS; + pthread_t *tids = calloc((size_t)total, sizeof(pthread_t)); + if (!tids) { free(work); return 0; } + + /* Launch first batch */ + int launched = 0, joined = 0; + for (; launched < pool; launched++) + pthread_create(&tids[launched], NULL, scan_worker, &work[launched]); + + /* Launch remaining, joining one before each new launch */ + for (; launched < total; launched++) { + pthread_join(tids[joined++], NULL); + pthread_create(&tids[launched], NULL, scan_worker, &work[launched]); + } + + /* Drain remaining */ + for (; joined < total; joined++) + pthread_join(tids[joined], NULL); + + /* Copy results out */ + for (int i = 0; i < total; i++) + results[i] = work[i].result; + + free(tids); + free(work); + return total; +} diff --git a/src/tui.c b/src/tui.c index 2498c6f..605fba6 100644 --- a/src/tui.c +++ b/src/tui.c @@ -10,7 +10,7 @@ #include /* ================================================================ - * SECTION 1 — TARGET PICKER + * SECTION 1 - TARGET PICKER * ================================================================ */ #define PICKER_MIN_W 40 @@ -29,18 +29,15 @@ int tui_pick_target(const MakeTargets *targets, char *selected, size_t sel_size) if (has_colors()) { start_color(); use_default_colors(); - init_pair(1, COLOR_BLACK, COLOR_CYAN); /* selected row */ - init_pair(2, COLOR_CYAN, -1); /* border colour */ - init_pair(3, COLOR_WHITE, -1); /* normal row */ + init_pair(1, COLOR_BLACK, COLOR_WHITE); /* selected row */ } int rows, cols; getmaxyx(stdscr, rows, cols); - int list_h = targets->count + 2; /* +2 for border */ + int list_h = targets->count + 2; int list_w = PICKER_MIN_W; - /* find longest target name and widen accordingly */ for (int i = 0; i < targets->count; i++) { int l = (int)strlen(targets->names[i]) + 4; if (l > list_w) list_w = l; @@ -51,17 +48,16 @@ int tui_pick_target(const MakeTargets *targets, char *selected, size_t sel_size) int win_y = (rows - list_h) / 2; int win_x = (cols - list_w) / 2; - /* instruction line above the box */ int cur = 0; int scroll_offset = 0; - int visible = list_h - 2; /* rows inside border */ + int visible = list_h - 2; WINDOW *win = newwin(list_h, list_w, win_y, win_x); + keypad(win, TRUE); - /* header hint */ attron(A_DIM); mvprintw(win_y - 2, win_x, - "Select make target K - UP J - DOWN to navigate Enter - select \u00b7 q - cancel"); + "Select make target J/K navigate | Enter select | q cancel"); attroff(A_DIM); refresh(); @@ -70,9 +66,7 @@ int tui_pick_target(const MakeTargets *targets, char *selected, size_t sel_size) while (!done) { werase(win); - if (has_colors()) wattron(win, COLOR_PAIR(2)); box(win, 0, 0); - if (has_colors()) wattroff(win, COLOR_PAIR(2)); for (int i = 0; i < visible && (i + scroll_offset) < targets->count; i++) { int idx = i + scroll_offset; @@ -84,10 +78,8 @@ int tui_pick_target(const MakeTargets *targets, char *selected, size_t sel_size) if (has_colors()) wattroff(win, COLOR_PAIR(1) | A_BOLD); else wattroff(win, A_REVERSE); } else { - if (has_colors()) wattron(win, COLOR_PAIR(3)); mvwprintw(win, i + 1, 1, " %-*s", list_w - 4, targets->names[idx]); - if (has_colors()) wattroff(win, COLOR_PAIR(3)); } } @@ -119,7 +111,7 @@ int tui_pick_target(const MakeTargets *targets, char *selected, size_t sel_size) done = 1; break; case 'q': - case 27: /* ESC */ + case 27: done = 1; break; } @@ -131,20 +123,19 @@ int tui_pick_target(const MakeTargets *targets, char *selected, size_t sel_size) } /* ================================================================ - * SECTION 2 — LOG BROWSER + * SECTION 2 - LOG BROWSER * ================================================================ */ #define MAX_LOGS 512 typedef struct { - char name[256]; /* filename only */ - char path[768]; /* full path */ + char name[256]; + char path[768]; time_t mtime; } LogEntry; static int log_entry_cmp(const void *a, const void *b) { - /* newest first */ const LogEntry *la = (const LogEntry *)a; const LogEntry *lb = (const LogEntry *)b; if (lb->mtime > la->mtime) return 1; @@ -162,7 +153,6 @@ static int load_log_entries(const char *log_dir, struct dirent *de; while ((de = readdir(d)) && count < max) { if (de->d_name[0] == '.') continue; - /* only .log files */ const char *dot = strrchr(de->d_name, '.'); if (!dot || strcmp(dot, ".log")) continue; @@ -184,11 +174,6 @@ static int load_log_entries(const char *log_dir, return count; } -/* - * Read up to `max_lines` trailing lines from `path` into `lines`. - * Returns the number of lines read. - * Caller owns the memory and should free each pointer. - */ #define PREVIEW_MAX 128 static int read_tail(const char *path, char **lines, int max_lines) @@ -196,17 +181,14 @@ static int read_tail(const char *path, char **lines, int max_lines) FILE *f = fopen(path, "r"); if (!f) return 0; - /* Circular buffer approach */ char **buf = calloc(max_lines, sizeof(char *)); if (!buf) { fclose(f); return 0; } char tmp[1024]; int idx = 0, total = 0; while (fgets(tmp, sizeof(tmp), f)) { - /* strip newline */ size_t l = strlen(tmp); if (l > 0 && tmp[l-1] == '\n') tmp[l-1] = '\0'; - free(buf[idx % max_lines]); buf[idx % max_lines] = strdup(tmp); idx++; @@ -214,13 +196,12 @@ static int read_tail(const char *path, char **lines, int max_lines) } fclose(f); - int have = (total < max_lines) ? total : max_lines; + int have = (total < max_lines) ? total : max_lines; int start = (total >= max_lines) ? (idx % max_lines) : 0; for (int i = 0; i < have; i++) lines[i] = buf[(start + i) % max_lines]; - /* free slots that weren't returned */ for (int i = have; i < max_lines; i++) { free(buf[(start + i) % max_lines]); buf[(start + i) % max_lines] = NULL; @@ -247,24 +228,20 @@ void tui_log_browser(const char *log_dir) if (has_colors()) { start_color(); use_default_colors(); - init_pair(1, COLOR_BLACK, COLOR_CYAN); /* selected row */ - init_pair(2, COLOR_CYAN, -1); /* border */ - init_pair(3, COLOR_WHITE, -1); /* normal row */ - init_pair(4, COLOR_YELLOW,-1); /* preview text */ - init_pair(5, COLOR_RED, -1); /* status/warn */ + init_pair(1, COLOR_BLACK, COLOR_WHITE); /* selected row */ } int rows, cols; getmaxyx(stdscr, rows, cols); - /* layout: left pane = 40% width, right pane = rest */ int left_w = cols * 40 / 100; if (left_w < 30) left_w = 30; - int right_w = cols - left_w - 1; /* -1 for divider */ - int pane_h = rows - 3; /* -3 for title + hint bar */ + int right_w = cols - left_w - 1; + int pane_h = rows - 3; WINDOW *wleft = newwin(pane_h, left_w, 1, 0); WINDOW *wright = newwin(pane_h, right_w, 1, left_w + 1); + keypad(wleft, TRUE); int cur = 0, scroll = 0; int done = 0; @@ -280,11 +257,11 @@ void tui_log_browser(const char *log_dir) clrtoeol(); /* ---- hint bar ---- */ - if (has_colors()) attron(COLOR_PAIR(2)); + attron(A_REVERSE); mvprintw(rows - 2, 0, - " \u2191\u2193 navigate Enter open in less d delete q quit " + " J/K navigate Enter open in less d delete q quit " " "); - if (has_colors()) attroff(COLOR_PAIR(2)); + attroff(A_REVERSE); /* ---- vertical divider ---- */ for (int y = 1; y < rows - 2; y++) @@ -292,9 +269,7 @@ void tui_log_browser(const char *log_dir) /* ---- left pane: file list ---- */ werase(wleft); - if (has_colors()) wattron(wleft, COLOR_PAIR(2)); box(wleft, 0, 0); - if (has_colors()) wattroff(wleft, COLOR_PAIR(2)); int visible = pane_h - 2; if (visible < 1) visible = 1; @@ -304,7 +279,6 @@ void tui_log_browser(const char *log_dir) } else { for (int i = 0; i < visible && (i + scroll) < count; i++) { int idx = i + scroll; - /* trim the .log extension for display */ char disp[256]; strncpy(disp, entries[idx].name, sizeof(disp) - 1); char *dot = strrchr(disp, '.'); @@ -323,20 +297,17 @@ void tui_log_browser(const char *log_dir) } } } - wrefresh(wleft); + /* NOTE: no wrefresh(wleft) here - flush everything together below */ /* ---- right pane: preview ---- */ werase(wright); - if (has_colors()) wattron(wright, COLOR_PAIR(2)); box(wright, 0, 0); - if (has_colors()) wattroff(wright, COLOR_PAIR(2)); if (count > 0 && cur < count) { int preview_lines = pane_h - 2; char **lines = calloc(preview_lines, sizeof(char *)); if (lines) { int n = read_tail(entries[cur].path, lines, preview_lines); - if (has_colors()) wattron(wright, COLOR_PAIR(4)); for (int i = 0; i < n; i++) { if (lines[i]) { mvwprintw(wright, i + 1, 1, "%-*.*s", @@ -344,17 +315,20 @@ void tui_log_browser(const char *log_dir) free(lines[i]); } } - if (has_colors()) wattroff(wright, COLOR_PAIR(4)); free(lines); } } else { mvwprintw(wright, 2, 2, "(no log selected)"); } - wrefresh(wright); - refresh(); + + /* Atomic flush - stdscr first, then both panes on top */ + wnoutrefresh(stdscr); + wnoutrefresh(wleft); + wnoutrefresh(wright); + doupdate(); /* ---- input ---- */ - int ch = getch(); + int ch = wgetch(wleft); switch (ch) { case KEY_UP: case 'k': @@ -380,7 +354,6 @@ void tui_log_browser(const char *log_dir) snprintf(cmd, sizeof(cmd), "less \"%s\"", entries[cur].path); system(cmd); - /* re-init after less exits */ initscr(); noecho(); cbreak(); @@ -389,25 +362,21 @@ void tui_log_browser(const char *log_dir) if (has_colors()) { start_color(); use_default_colors(); - init_pair(1, COLOR_BLACK, COLOR_CYAN); - init_pair(2, COLOR_CYAN, -1); - init_pair(3, COLOR_WHITE, -1); - init_pair(4, COLOR_YELLOW,-1); - init_pair(5, COLOR_RED, -1); + init_pair(1, COLOR_BLACK, COLOR_WHITE); } + touchwin(wleft); + touchwin(wright); clear(); } break; case 'd': if (count > 0 && cur < count) { - /* confirm */ mvprintw(rows - 1, 0, "Delete %s? [y/N] ", entries[cur].name); refresh(); int confirm = getch(); if (confirm == 'y' || confirm == 'Y') { remove(entries[cur].path); - /* reload list */ count = load_log_entries(log_dir, entries, MAX_LOGS); if (cur >= count) cur = count > 0 ? count - 1 : 0; if (scroll > cur) scroll = cur; @@ -416,7 +385,7 @@ void tui_log_browser(const char *log_dir) } break; case 'q': - case 27: /* ESC */ + case 27: done = 1; break; } @@ -427,3 +396,202 @@ void tui_log_browser(const char *log_dir) endwin(); free(entries); } + +/* ================================================================ + * SECTION 3 - PROJECT OVERVIEW + * ================================================================ */ + +/* + * Column layout: + * ST PROJECT NAME LAST BUILD RESULT HEAD HASH + * + * Status symbols: + * * last build passed + * X last build failed + * - never built + * + * Keys: + * J/K navigate Enter build l logs q/ESC quit + */ + +#include "index.h" +#include "git_ops.h" + +#define COL_STATUS_W 3 +#define COL_NAME_W 24 +#define COL_TIME_W 18 +#define COL_RC_W 8 +#define COL_HASH_W 10 + +static void fmt_ts(time_t ts, char *out, size_t n) +{ + if (ts == 0) { + strncpy(out, "(never)", n - 1); + out[n-1] = '\0'; + return; + } + struct tm *t = localtime(&ts); + strftime(out, n, "%d %b %H:%M", t); +} + +int tui_project_overview(ProjectIndex *idx, + const char *clone_dir, + const char *log_dir, + char *selected_project, + size_t sel_size) +{ + (void)clone_dir; + + if (!idx || idx->count == 0) return -1; + + initscr(); + noecho(); + cbreak(); + keypad(stdscr, TRUE); + curs_set(0); + + if (has_colors()) { + start_color(); + use_default_colors(); + init_pair(1, COLOR_BLACK, COLOR_WHITE); /* selected row */ + } + + int rows, cols; + getmaxyx(stdscr, rows, cols); + + int cur = 0, scroll = 0; + int done = 0; + int result = -1; + + while (!done) { + getmaxyx(stdscr, rows, cols); + int visible = rows - 5; + if (visible < 1) visible = 1; + + erase(); + + /* ---- title bar ---- */ + attron(A_BOLD); + mvprintw(0, 2, "gbuild 1.0.0 - project overview"); + attroff(A_BOLD); + + /* ---- column header ---- */ + attron(A_BOLD); + mvprintw(2, 2, " %-*s %-*s %-*s %-*s", + COL_NAME_W, "PROJECT", + COL_TIME_W, "LAST BUILD", + COL_RC_W, "RESULT", + COL_HASH_W, "HEAD"); + attroff(A_BOLD); + + /* separator */ + mvhline(3, 0, ACS_HLINE, cols); + + /* ---- project rows ---- */ + for (int i = 0; i < visible && (i + scroll) < idx->count; i++) { + int idx_i = i + scroll; + ProjectRecord *r = &idx->projects[idx_i]; + + char ts_str[32]; + fmt_ts(r->last_build_ts, ts_str, sizeof(ts_str)); + + char shorthash[12] = "--------"; + if (r->last_head_hash[0]) + strncpy(shorthash, r->last_head_hash, 8); + shorthash[8] = '\0'; + + char rc_str[16]; + if (r->last_build_rc == -1) strncpy(rc_str, "-", sizeof(rc_str) - 1); + else if (r->last_build_rc == 0) strncpy(rc_str, "PASS", sizeof(rc_str) - 1); + else snprintf(rc_str, sizeof(rc_str), "FAIL(%d)", r->last_build_rc); + + const char *sym = (r->last_build_rc == 0) ? "*" : + (r->last_build_rc > 0) ? "X" : "-"; + + int y = 4 + i; + + if (idx_i == cur) { + /* fill the whole row first */ + if (has_colors()) attron(COLOR_PAIR(1) | A_BOLD); + else attron(A_REVERSE); + mvprintw(y, 0, "%*s", cols, ""); + mvprintw(y, 2, "%s %-*s %-*s %-*s %-*s", + sym, + COL_NAME_W, r->name, + COL_TIME_W, ts_str, + COL_RC_W, rc_str, + COL_HASH_W, shorthash); + if (has_colors()) attroff(COLOR_PAIR(1) | A_BOLD); + else attroff(A_REVERSE); + } else { + mvprintw(y, 2, "%s %-*s %-*s %-*s %-*s", + sym, + COL_NAME_W, r->name, + COL_TIME_W, ts_str, + COL_RC_W, rc_str, + COL_HASH_W, shorthash); + } + } + + /* ---- hint bar ---- */ + attron(A_REVERSE); + mvprintw(rows - 1, 0, + " J/K navigate Enter build l logs q quit" + " "); + attroff(A_REVERSE); + + wnoutrefresh(stdscr); + doupdate(); + + /* ---- input ---- */ + int ch = getch(); + switch (ch) { + case KEY_UP: + case 'k': + if (cur > 0) { + cur--; + if (cur < scroll) scroll = cur; + } + break; + case KEY_DOWN: + case 'j': + if (cur < idx->count - 1) { + cur++; + if (cur >= scroll + visible) + scroll = cur - visible + 1; + } + break; + case '\n': + case '\r': + case KEY_ENTER: + strncpy(selected_project, + idx->projects[cur].name, sel_size - 1); + selected_project[sel_size - 1] = '\0'; + result = cur; + done = 1; + break; + case 'l': + endwin(); + tui_log_browser(log_dir); + initscr(); + noecho(); + cbreak(); + keypad(stdscr, TRUE); + curs_set(0); + if (has_colors()) { + start_color(); + use_default_colors(); + init_pair(1, COLOR_BLACK, COLOR_WHITE); + } + clear(); + break; + case 'q': + case 27: + done = 1; + break; + } + } + + endwin(); + return result; +}