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 6c3664f..642f398 100644 Binary files a/bin/gbuild and b/bin/gbuild differ diff --git a/bin/gconfig b/bin/gconfig index 2b1ee61..f77a9cf 100644 Binary files a/bin/gconfig and b/bin/gconfig differ 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; +}