Added features

Added: 
Project Overview TUI 
Build Cache / Dirty Detection
Post-Build Hooks
gbuild status
This commit is contained in:
Schmidt Peter
2026-05-23 21:03:54 +02:00
committed by GitHub
parent 351ce1b06e
commit 939232e72e
18 changed files with 2768 additions and 272 deletions
+4 -1
View File
@@ -4,7 +4,7 @@
CC = gcc CC = gcc
CFLAGS = -Wall -Wextra -O2 -Iinclude CFLAGS = -Wall -Wextra -O2 -Iinclude
LDLIBS_GBUILD = -lncurses LDLIBS_GBUILD = -lncurses -lpthread
LDLIBS_GCONFIG = LDLIBS_GCONFIG =
# ---- package metadata -------------------------------------- # ---- package metadata --------------------------------------
@@ -29,7 +29,10 @@ COMMON_SRCS = src/config.c
GBUILD_SRCS = \ GBUILD_SRCS = \
src/gbuild.c \ src/gbuild.c \
src/cache.c \
src/hooks.c \
src/git_ops.c \ src/git_ops.c \
src/index.c \
src/logger.c \ src/logger.c \
src/make_ops.c \ src/make_ops.c \
src/tui.c \ src/tui.c \
+71 -95
View File
@@ -1,79 +1,75 @@
# gbuild # gbuild
A C rewrite of the original [gbuild](https://spdlab.hu/gbuild) bash tool. gbuild started as a bash script to stop me from typing the same git-clone,
Clone, pull, pick a target, build — all from one command. 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 Run `gbuild` with no arguments and you get a project overview — every repo
- **Interactive target picker** — reads your `Makefile` and presents an ncurses TUI to select the build target; pass `--target` to skip it you've built before, when you last built it, whether it passed or failed,
- **Timestamped log files** — every run writes a log to `~/.local/log/gbuild/` named `<timestamp>_<project>.log` and the HEAD hash it was on. Pick one with J/K and hit Enter.
- **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 Run `gbuild myproject` to go straight to a specific project. If it hasn't
- **`gconfig` companion** — manages `~/.gconfig` with `init`, `show`, and `help` commands 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 ## Dependencies
| Library | Purpose | You'll need `gcc`, `make`, `git`, `less`, and the ncurses development headers.
|----------|------------------------------| Most systems have everything except the ncurses headers — the package is
| ncurses | TUI for picker & log browser | usually called `libncurses-dev`, `ncurses-devel`, or just `ncurses` depending
| git | Clone / pull | on your distribution.
| make | Build projects |
| less | Open logs from browser |
--- ---
## Build ## Building
### Linux
```sh ```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 make
# Optionally install system-wide
sudo make install 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 ```sh
# 1. Initialise ~/.gconfig with defaults gconfig init # creates ~/.gconfig with defaults
gconfig init $EDITOR ~/.gconfig # fill in your server URL and credentials
gconfig show # check it looks right
# 2. Fill in your values gbuild # open the overview and pick something to build
$EDITOR ~/.gconfig
# 3. Verify
gconfig show
# 4. Build a project
gbuild myproject
``` ```
### `~/.gconfig` format Your `~/.gconfig` looks like this:
```ini ```ini
# ~/.gconfig — managed by gconfig
[git] [git]
GIT_URL = http://localhost:3000 GIT_URL = http://localhost:3000
GIT_USER = Username GIT_USER = myuser
[auth] [auth]
# Use token-based OR password-based auth; leave the other blank # token or password, leave the other blank
GIT_TOKEN = GIT_TOKEN =
GIT_PASSWORD = GIT_PASSWORD =
@@ -89,70 +85,50 @@ LOG_DIR = ~/.local/log/gbuild
--- ---
## Usage ## Usage
``` ```
gbuild [options] <project> gbuild open the project overview
gbuild --logs gbuild <project> clone/pull and build
gbuild --target <t> <p> skip the picker, run target t
Options: gbuild --logs open the log browser
--url <url> Override Git base URL (default: ~/.gconfig) gbuild --no-tui print usage (useful in scripts)
--user <name> Override Git username (default: ~/.gconfig) gbuild --url --user [username] [projectname] one-off config override
--target <tgt> Run target directly, skip interactive picker
--logs Open the interactive log browser
-h, --help Print this help and exit
``` ```
### 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 ## gconfig
```
| Command | Description | gconfig init create ~/.gconfig (won't overwrite)
|---------------------|-------------------------------------------------------------| gconfig init --force overwrite, backs up the old file first
| `gconfig init` | Create `~/.gconfig` with defaults (skips if already exists) | gconfig show print config, masks auth fields
| `gconfig init --force` | Overwrite; backs up old file as `~/.gconfig.bak.<ts>` | gconfig help
| `gconfig show` | Print current config (auth fields masked) | ```
| `gconfig help` | Print usage |
--- ---
## Project layout ## Project layout
``` ```
gbuild/ gbuild/
├── Makefile ├── Makefile
├── README.md ├── README.md
├── include/ ├── include/
│ ├── config.h — GConfig struct, INI load/save/init/show │ ├── config.h ~/.gconfig read/write
│ ├── git_ops.h clone / pull │ ├── git_ops.h clone, pull, HEAD hash
│ ├── logger.h — timestamped coloured logging │ ├── index.h ~/.gbuild_index — persistent build history
│ ├── make_ops.h — Makefile target parser + build runner │ ├── logger.h terminal output + log files
── tui.h — ncurses target picker + log browser ── make_ops.h Makefile parser and build runner
│ └── tui.h all three ncurses screens
├── pkg/
│ ├── deb/ .deb package templates
│ └── rpm/ .rpm spec template
└── src/ └── src/
├── config.c ├── gbuild.c
├── gbuild.c — gbuild main() ├── gconfig.c
├── gconfig.c — gconfig main() ├── config.c
├── git_ops.c ├── git_ops.c
├── logger.c ├── index.c
├── make_ops.c ├── logger.c
└── tui.c ├── make_ops.c
└── tui.c
``` ```
--- ---
MIT License — maintained by jokerz / spdlab.hu MIT Copyright (c) Schmidt Peter Daniel 2026
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+1352
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -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 <stddef.h>
/* 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 */
+28
View File
@@ -6,6 +6,18 @@
#define CFG_MAX 512 #define CFG_MAX 512
/*
* Per-project hook override loaded from a [project:<name>] 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 { typedef struct {
char git_url[CFG_MAX]; char git_url[CFG_MAX];
char git_user[256]; char git_user[256];
@@ -15,8 +27,24 @@ typedef struct {
char clone_dir[CFG_MAX]; char clone_dir[CFG_MAX];
bool log_enabled; bool log_enabled;
char log_dir[CFG_MAX]; 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:<name>] sections. */
GProjectOverride project_overrides[CFG_MAX_PROJECT_OVERRIDES];
int project_override_count;
} GConfig; } GConfig;
/*
* Return the effective post-build hook for a given project name.
* Checks [project:<name>] 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); void config_defaults(GConfig *cfg);
int config_load(GConfig *cfg, const char *path); int config_load(GConfig *cfg, const char *path);
int config_save(const GConfig *cfg, const char *path); int config_save(const GConfig *cfg, const char *path);
+16
View File
@@ -15,4 +15,20 @@ int git_clone(const char *base_url, const char *user,
/* Pull latest in repo_path */ /* Pull latest in repo_path */
int git_pull(const char *repo_path, Logger *log); 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 */ #endif /* GIT_OPS_H */
+18
View File
@@ -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 */
+82
View File
@@ -0,0 +1,82 @@
#ifndef INDEX_H
#define INDEX_H
#include <time.h>
#include <stddef.h>
#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 */
+13
View File
@@ -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 */ * Keys: ↑↓ navigate Enter open in less d delete q quit */
void tui_log_browser(const char *log_dir); 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 */ #endif /* TUI_H */
+65
View File
@@ -0,0 +1,65 @@
#include "cache.h"
#include <stdio.h>
#include <string.h>
#include <errno.h>
/* 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;
}
+75 -15
View File
@@ -1,12 +1,3 @@
/*
This file is licensed under the MIT license.
Copyright (c) Schmidt Peter Daniel 2026
*/
#include "config.h" #include "config.h"
#include <stdio.h> #include <stdio.h>
@@ -51,6 +42,9 @@ void config_defaults(GConfig *cfg)
if (!home) home = "/tmp"; if (!home) home = "/tmp";
snprintf(cfg->clone_dir, CFG_MAX, "%s/projects", home); snprintf(cfg->clone_dir, CFG_MAX, "%s/projects", home);
snprintf(cfg->log_dir, CFG_MAX, "%s/.local/log/gbuild", home); snprintf(cfg->log_dir, CFG_MAX, "%s/.local/log/gbuild", home);
cfg->post_build_hook[0] = '\0';
cfg->project_override_count = 0;
} }
/* ------------------------------------------------------------------ loader */ /* ------------------------------------------------------------------ loader */
@@ -76,13 +70,13 @@ int config_load(GConfig *cfg, const char *path)
if (!f) return -1; if (!f) return -1;
char line[512]; char line[512];
char section[64] = ""; char section[128] = ""; /* e.g. "build", "project:myproject" */
while (fgets(line, sizeof(line), f)) { while (fgets(line, sizeof(line), f)) {
strip(line); strip(line);
if (line[0] == '\0' || line[0] == '#') continue; if (line[0] == '\0' || line[0] == '#') continue;
/* section header */ /* ---- section header ---- */
if (line[0] == '[') { if (line[0] == '[') {
char *end = strchr(line, ']'); char *end = strchr(line, ']');
if (end) { if (end) {
@@ -92,7 +86,7 @@ int config_load(GConfig *cfg, const char *path)
continue; continue;
} }
/* key = value */ /* ---- key = value ---- */
char *eq = strchr(line, '='); char *eq = strchr(line, '=');
if (!eq) continue; if (!eq) continue;
*eq = '\0'; *eq = '\0';
@@ -104,6 +98,30 @@ int config_load(GConfig *cfg, const char *path)
char expanded[CFG_MAX]; char expanded[CFG_MAX];
expand_tilde(val, expanded, sizeof(expanded)); expand_tilde(val, expanded, sizeof(expanded));
/* ---- [project:<name>] 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); 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_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); 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_DIR")) strncpy(cfg->log_dir, expanded, CFG_MAX - 1);
else if (!strcmp(key, "LOG_ENABLED")) cfg->log_enabled = (strcmp(expanded, "false") != 0 && else if (!strcmp(key, "LOG_ENABLED")) cfg->log_enabled = (strcmp(expanded, "false") != 0 &&
strcmp(expanded, "0") != 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); fclose(f);
@@ -138,7 +156,10 @@ int config_save(const GConfig *cfg, const char *path)
"GIT_PASSWORD = %s\n\n" "GIT_PASSWORD = %s\n\n"
"[build]\n" "[build]\n"
"DEFAULT_TARGET = %s\n" "DEFAULT_TARGET = %s\n"
"CLONE_DIR = %s\n\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]\n"
"LOG_ENABLED = %s\n" "LOG_ENABLED = %s\n"
"LOG_DIR = %s\n", "LOG_DIR = %s\n",
@@ -148,9 +169,21 @@ int config_save(const GConfig *cfg, const char *path)
cfg->git_password, cfg->git_password,
cfg->default_target, cfg->default_target,
cfg->clone_dir, cfg->clone_dir,
cfg->post_build_hook,
cfg->log_enabled ? "true" : "false", cfg->log_enabled ? "true" : "false",
cfg->log_dir); 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); fclose(f);
return 0; return 0;
} }
@@ -197,8 +230,35 @@ void config_show(const GConfig *cfg)
printf("\n [build]\n"); printf("\n [build]\n");
printf(" DEFAULT_TARGET = %s\n", cfg->default_target[0] ? cfg->default_target : "(not set)"); printf(" DEFAULT_TARGET = %s\n", cfg->default_target[0] ? cfg->default_target : "(not set)");
printf(" CLONE_DIR = %s\n", cfg->clone_dir); 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("\n [log]\n");
printf(" LOG_ENABLED = %s\n", cfg->log_enabled ? "true" : "false"); 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;
} }
+302 -83
View File
@@ -1,5 +1,8 @@
#include "cache.h"
#include "config.h" #include "config.h"
#include "hooks.h"
#include "git_ops.h" #include "git_ops.h"
#include "index.h"
#include "logger.h" #include "logger.h"
#include "make_ops.h" #include "make_ops.h"
#include "tui.h" #include "tui.h"
@@ -8,6 +11,7 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <time.h>
#include <unistd.h> #include <unistd.h>
#define VERSION "1.0.0" #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" "gbuild %s — A build tool for your Linux distro\n\n"
"Usage:\n" "Usage:\n"
" %s [options] <project>\n" " %s [options] <project>\n"
" %s (no args — opens project overview TUI)\n"
" %s --logs\n\n" " %s --logs\n\n"
"Options:\n" "Options:\n"
" --url <url> Override Git base URL (default: ~/.gconfig)\n" " --url <url> Override Git base URL (default: ~/.gconfig)\n"
" --user <name> Override Git username (default: ~/.gconfig)\n" " --user <name> Override Git username (default: ~/.gconfig)\n"
" --target <tgt> Run target directly, skip interactive picker\n" " --target <tgt> 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" " --logs Open the interactive log browser\n"
" --no-tui With no <project>, print usage instead of opening TUI\n"
" -h, --help Print this help and exit\n\n" " -h, --help Print this help and exit\n\n"
"Examples:\n" "Examples:\n"
" gbuild (overview TUI — pick and build)\n"
" gbuild myproject\n" " gbuild myproject\n"
" gbuild --force myproject\n"
" gbuild --status\n"
" gbuild --status --no-color | grep dirty\n"
" gbuild --target clean myproject\n" " gbuild --target clean myproject\n"
" gbuild --url http://10.0.0.5:3000 --user alice myproject\n" " gbuild --url http://10.0.0.5:3000 --user alice myproject\n"
" gbuild --logs\n\n" " gbuild --logs\n\n"
"Configuration is read from ~/.gconfig. Run 'gconfig init' to set it up.\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 */ /* ----------------------------------------------------------------- main */
@@ -46,6 +302,10 @@ int main(int argc, char *argv[])
const char *arg_target = NULL; const char *arg_target = NULL;
const char *arg_project = 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++) { for (int i = 1; i < argc; i++) {
if (!strcmp(argv[i], "-h") || !strcmp(argv[i], "--help")) { 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")) { } else if (!strcmp(argv[i], "--target")) {
if (++i >= argc) { fprintf(stderr, "error: --target requires a value\n"); return 1; } if (++i >= argc) { fprintf(stderr, "error: --target requires a value\n"); return 1; }
arg_target = argv[i]; 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")) { } else if (!strcmp(argv[i], "--logs")) {
flag_logs = 1; flag_logs = 1;
} else if (!strcmp(argv[i], "--no-tui")) {
flag_no_tui = 1;
} else if (argv[i][0] == '-') { } else if (argv[i][0] == '-') {
fprintf(stderr, "error: unknown flag '%s'\n", argv[i]); fprintf(stderr, "error: unknown flag '%s'\n", argv[i]);
return 1; return 1;
@@ -86,109 +354,60 @@ int main(int argc, char *argv[])
config_defaults(&cfg); config_defaults(&cfg);
} }
/* apply overrides */
if (arg_url) strncpy(cfg.git_url, arg_url, CFG_MAX - 1); 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); 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]; char clone_dir[CFG_MAX], log_dir[CFG_MAX];
expand_tilde(cfg.clone_dir, clone_dir, sizeof(clone_dir)); expand_tilde(cfg.clone_dir, clone_dir, sizeof(clone_dir));
expand_tilde(cfg.log_dir, log_dir, sizeof(log_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 ---- */ /* ---- log browser mode ---- */
if (flag_logs) { if (flag_logs) {
tui_log_browser(log_dir); tui_log_browser(log_dir);
return 0; return 0;
} }
/* ---- need a project name ---- */ /* ---- no project given: open overview TUI or print usage ---- */
if (!arg_project) { if (!arg_project) {
fprintf(stderr, "error: no project specified.\n\n"); if (flag_no_tui || idx.count == 0) {
print_usage(argv[0]); if (idx.count == 0 && !flag_no_tui)
return 1; fprintf(stderr,
} "No projects found in %s.\n"
"Run 'gbuild <project>' to clone and build one first.\n",
/* ---- open logger ---- */ clone_dir);
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 else
fprintf(stderr, "[WARN ] Could not open log file in %s\n", log_dir); print_usage(argv[0]);
return (idx.count == 0) ? 1 : 0;
} }
logger_info(&log, "gbuild started for project: %s", arg_project); 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 */
/* ---- clone or pull ---- */ arg_project = chosen;
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;
}
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));
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)");
} }
/* ---- build ---- */ /* ---- build ---- */
logger_info(&log, "gbuild: building '%s' target '%s' ...", int rc = build_project(arg_project, arg_target,
arg_project, target[0] ? target : "(default)"); &cfg, clone_dir, log_dir, &idx, flag_force);
int rc = make_build(repo_path, target, &log); /* ---- persist updated index ---- */
if (rc != 0) { index_save(&idx, idx_path);
logger_error(&log, "Build failed (exit %d).", rc);
logger_close(&log);
return 1;
}
logger_ok(&log, "gbuild complete."); return rc;
logger_close(&log);
return 0;
} }
+110 -8
View File
@@ -1,13 +1,6 @@
/*
This file is licensed under the MIT license.
Copyright (c) Schmidt Peter Daniel 2026.
*/
#include "git_ops.h" #include "git_ops.h"
#include "config.h" #include "config.h"
#include "index.h"
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
@@ -131,3 +124,112 @@ int git_pull(const char *repo_path, Logger *log)
"git -C \"%s\" pull 2>&1", repo_path); "git -C \"%s\" pull 2>&1", repo_path);
return run_stream(cmd, log); 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);
}
+82
View File
@@ -0,0 +1,82 @@
#include "hooks.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <sys/wait.h>
#include <unistd.h>
/* ----------------------------------------------------------------- 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 '<escaped_working_dir>' && <cmd>
*
* 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 '<dir>' && <user_cmd>, 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);
}
+277
View File
@@ -0,0 +1,277 @@
#include "index.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <dirent.h>
#include <errno.h>
/* ---------------------------------------------------------------- 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 <pthread.h>
/* 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;
}
+228 -60
View File
@@ -10,7 +10,7 @@
#include <errno.h> #include <errno.h>
/* ================================================================ /* ================================================================
* SECTION 1 TARGET PICKER * SECTION 1 - TARGET PICKER
* ================================================================ */ * ================================================================ */
#define PICKER_MIN_W 40 #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()) { if (has_colors()) {
start_color(); start_color();
use_default_colors(); use_default_colors();
init_pair(1, COLOR_BLACK, COLOR_CYAN); /* selected row */ init_pair(1, COLOR_BLACK, COLOR_WHITE); /* selected row */
init_pair(2, COLOR_CYAN, -1); /* border colour */
init_pair(3, COLOR_WHITE, -1); /* normal row */
} }
int rows, cols; int rows, cols;
getmaxyx(stdscr, 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; int list_w = PICKER_MIN_W;
/* find longest target name and widen accordingly */
for (int i = 0; i < targets->count; i++) { for (int i = 0; i < targets->count; i++) {
int l = (int)strlen(targets->names[i]) + 4; int l = (int)strlen(targets->names[i]) + 4;
if (l > list_w) list_w = l; 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_y = (rows - list_h) / 2;
int win_x = (cols - list_w) / 2; int win_x = (cols - list_w) / 2;
/* instruction line above the box */
int cur = 0; int cur = 0;
int scroll_offset = 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); WINDOW *win = newwin(list_h, list_w, win_y, win_x);
keypad(win, TRUE);
/* header hint */
attron(A_DIM); attron(A_DIM);
mvprintw(win_y - 2, win_x, 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); attroff(A_DIM);
refresh(); refresh();
@@ -70,9 +66,7 @@ int tui_pick_target(const MakeTargets *targets, char *selected, size_t sel_size)
while (!done) { while (!done) {
werase(win); werase(win);
if (has_colors()) wattron(win, COLOR_PAIR(2));
box(win, 0, 0); box(win, 0, 0);
if (has_colors()) wattroff(win, COLOR_PAIR(2));
for (int i = 0; i < visible && (i + scroll_offset) < targets->count; i++) { for (int i = 0; i < visible && (i + scroll_offset) < targets->count; i++) {
int idx = i + scroll_offset; 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); if (has_colors()) wattroff(win, COLOR_PAIR(1) | A_BOLD);
else wattroff(win, A_REVERSE); else wattroff(win, A_REVERSE);
} else { } else {
if (has_colors()) wattron(win, COLOR_PAIR(3));
mvwprintw(win, i + 1, 1, " %-*s", mvwprintw(win, i + 1, 1, " %-*s",
list_w - 4, targets->names[idx]); 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; done = 1;
break; break;
case 'q': case 'q':
case 27: /* ESC */ case 27:
done = 1; done = 1;
break; 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 #define MAX_LOGS 512
typedef struct { typedef struct {
char name[256]; /* filename only */ char name[256];
char path[768]; /* full path */ char path[768];
time_t mtime; time_t mtime;
} LogEntry; } LogEntry;
static int log_entry_cmp(const void *a, const void *b) static int log_entry_cmp(const void *a, const void *b)
{ {
/* newest first */
const LogEntry *la = (const LogEntry *)a; const LogEntry *la = (const LogEntry *)a;
const LogEntry *lb = (const LogEntry *)b; const LogEntry *lb = (const LogEntry *)b;
if (lb->mtime > la->mtime) return 1; if (lb->mtime > la->mtime) return 1;
@@ -162,7 +153,6 @@ static int load_log_entries(const char *log_dir,
struct dirent *de; struct dirent *de;
while ((de = readdir(d)) && count < max) { while ((de = readdir(d)) && count < max) {
if (de->d_name[0] == '.') continue; if (de->d_name[0] == '.') continue;
/* only .log files */
const char *dot = strrchr(de->d_name, '.'); const char *dot = strrchr(de->d_name, '.');
if (!dot || strcmp(dot, ".log")) continue; if (!dot || strcmp(dot, ".log")) continue;
@@ -184,11 +174,6 @@ static int load_log_entries(const char *log_dir,
return count; 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 #define PREVIEW_MAX 128
static int read_tail(const char *path, char **lines, int max_lines) 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"); FILE *f = fopen(path, "r");
if (!f) return 0; if (!f) return 0;
/* Circular buffer approach */
char **buf = calloc(max_lines, sizeof(char *)); char **buf = calloc(max_lines, sizeof(char *));
if (!buf) { fclose(f); return 0; } if (!buf) { fclose(f); return 0; }
char tmp[1024]; char tmp[1024];
int idx = 0, total = 0; int idx = 0, total = 0;
while (fgets(tmp, sizeof(tmp), f)) { while (fgets(tmp, sizeof(tmp), f)) {
/* strip newline */
size_t l = strlen(tmp); size_t l = strlen(tmp);
if (l > 0 && tmp[l-1] == '\n') tmp[l-1] = '\0'; if (l > 0 && tmp[l-1] == '\n') tmp[l-1] = '\0';
free(buf[idx % max_lines]); free(buf[idx % max_lines]);
buf[idx % max_lines] = strdup(tmp); buf[idx % max_lines] = strdup(tmp);
idx++; idx++;
@@ -220,7 +202,6 @@ static int read_tail(const char *path, char **lines, int max_lines)
for (int i = 0; i < have; i++) for (int i = 0; i < have; i++)
lines[i] = buf[(start + i) % max_lines]; lines[i] = buf[(start + i) % max_lines];
/* free slots that weren't returned */
for (int i = have; i < max_lines; i++) { for (int i = have; i < max_lines; i++) {
free(buf[(start + i) % max_lines]); free(buf[(start + i) % max_lines]);
buf[(start + i) % max_lines] = NULL; buf[(start + i) % max_lines] = NULL;
@@ -247,24 +228,20 @@ void tui_log_browser(const char *log_dir)
if (has_colors()) { if (has_colors()) {
start_color(); start_color();
use_default_colors(); use_default_colors();
init_pair(1, COLOR_BLACK, COLOR_CYAN); /* selected row */ init_pair(1, COLOR_BLACK, COLOR_WHITE); /* 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 */
} }
int rows, cols; int rows, cols;
getmaxyx(stdscr, rows, cols); getmaxyx(stdscr, rows, cols);
/* layout: left pane = 40% width, right pane = rest */
int left_w = cols * 40 / 100; int left_w = cols * 40 / 100;
if (left_w < 30) left_w = 30; if (left_w < 30) left_w = 30;
int right_w = cols - left_w - 1; /* -1 for divider */ int right_w = cols - left_w - 1;
int pane_h = rows - 3; /* -3 for title + hint bar */ int pane_h = rows - 3;
WINDOW *wleft = newwin(pane_h, left_w, 1, 0); WINDOW *wleft = newwin(pane_h, left_w, 1, 0);
WINDOW *wright = newwin(pane_h, right_w, 1, left_w + 1); WINDOW *wright = newwin(pane_h, right_w, 1, left_w + 1);
keypad(wleft, TRUE);
int cur = 0, scroll = 0; int cur = 0, scroll = 0;
int done = 0; int done = 0;
@@ -280,11 +257,11 @@ void tui_log_browser(const char *log_dir)
clrtoeol(); clrtoeol();
/* ---- hint bar ---- */ /* ---- hint bar ---- */
if (has_colors()) attron(COLOR_PAIR(2)); attron(A_REVERSE);
mvprintw(rows - 2, 0, 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 ---- */ /* ---- vertical divider ---- */
for (int y = 1; y < rows - 2; y++) for (int y = 1; y < rows - 2; y++)
@@ -292,9 +269,7 @@ void tui_log_browser(const char *log_dir)
/* ---- left pane: file list ---- */ /* ---- left pane: file list ---- */
werase(wleft); werase(wleft);
if (has_colors()) wattron(wleft, COLOR_PAIR(2));
box(wleft, 0, 0); box(wleft, 0, 0);
if (has_colors()) wattroff(wleft, COLOR_PAIR(2));
int visible = pane_h - 2; int visible = pane_h - 2;
if (visible < 1) visible = 1; if (visible < 1) visible = 1;
@@ -304,7 +279,6 @@ void tui_log_browser(const char *log_dir)
} else { } else {
for (int i = 0; i < visible && (i + scroll) < count; i++) { for (int i = 0; i < visible && (i + scroll) < count; i++) {
int idx = i + scroll; int idx = i + scroll;
/* trim the .log extension for display */
char disp[256]; char disp[256];
strncpy(disp, entries[idx].name, sizeof(disp) - 1); strncpy(disp, entries[idx].name, sizeof(disp) - 1);
char *dot = strrchr(disp, '.'); 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 ---- */ /* ---- right pane: preview ---- */
werase(wright); werase(wright);
if (has_colors()) wattron(wright, COLOR_PAIR(2));
box(wright, 0, 0); box(wright, 0, 0);
if (has_colors()) wattroff(wright, COLOR_PAIR(2));
if (count > 0 && cur < count) { if (count > 0 && cur < count) {
int preview_lines = pane_h - 2; int preview_lines = pane_h - 2;
char **lines = calloc(preview_lines, sizeof(char *)); char **lines = calloc(preview_lines, sizeof(char *));
if (lines) { if (lines) {
int n = read_tail(entries[cur].path, lines, preview_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++) { for (int i = 0; i < n; i++) {
if (lines[i]) { if (lines[i]) {
mvwprintw(wright, i + 1, 1, "%-*.*s", mvwprintw(wright, i + 1, 1, "%-*.*s",
@@ -344,17 +315,20 @@ void tui_log_browser(const char *log_dir)
free(lines[i]); free(lines[i]);
} }
} }
if (has_colors()) wattroff(wright, COLOR_PAIR(4));
free(lines); free(lines);
} }
} else { } else {
mvwprintw(wright, 2, 2, "(no log selected)"); 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 ---- */ /* ---- input ---- */
int ch = getch(); int ch = wgetch(wleft);
switch (ch) { switch (ch) {
case KEY_UP: case KEY_UP:
case 'k': case 'k':
@@ -380,7 +354,6 @@ void tui_log_browser(const char *log_dir)
snprintf(cmd, sizeof(cmd), snprintf(cmd, sizeof(cmd),
"less \"%s\"", entries[cur].path); "less \"%s\"", entries[cur].path);
system(cmd); system(cmd);
/* re-init after less exits */
initscr(); initscr();
noecho(); noecho();
cbreak(); cbreak();
@@ -389,25 +362,21 @@ void tui_log_browser(const char *log_dir)
if (has_colors()) { if (has_colors()) {
start_color(); start_color();
use_default_colors(); use_default_colors();
init_pair(1, COLOR_BLACK, COLOR_CYAN); init_pair(1, COLOR_BLACK, COLOR_WHITE);
init_pair(2, COLOR_CYAN, -1);
init_pair(3, COLOR_WHITE, -1);
init_pair(4, COLOR_YELLOW,-1);
init_pair(5, COLOR_RED, -1);
} }
touchwin(wleft);
touchwin(wright);
clear(); clear();
} }
break; break;
case 'd': case 'd':
if (count > 0 && cur < count) { if (count > 0 && cur < count) {
/* confirm */
mvprintw(rows - 1, 0, mvprintw(rows - 1, 0,
"Delete %s? [y/N] ", entries[cur].name); "Delete %s? [y/N] ", entries[cur].name);
refresh(); refresh();
int confirm = getch(); int confirm = getch();
if (confirm == 'y' || confirm == 'Y') { if (confirm == 'y' || confirm == 'Y') {
remove(entries[cur].path); remove(entries[cur].path);
/* reload list */
count = load_log_entries(log_dir, entries, MAX_LOGS); count = load_log_entries(log_dir, entries, MAX_LOGS);
if (cur >= count) cur = count > 0 ? count - 1 : 0; if (cur >= count) cur = count > 0 ? count - 1 : 0;
if (scroll > cur) scroll = cur; if (scroll > cur) scroll = cur;
@@ -416,7 +385,7 @@ void tui_log_browser(const char *log_dir)
} }
break; break;
case 'q': case 'q':
case 27: /* ESC */ case 27:
done = 1; done = 1;
break; break;
} }
@@ -427,3 +396,202 @@ void tui_log_browser(const char *log_dir)
endwin(); endwin();
free(entries); 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;
}