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
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 \
+72 -96
View File
@@ -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 `<timestamp>_<project>.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] <project>
gbuild --logs
Options:
--url <url> Override Git base URL (default: ~/.gconfig)
--user <name> Override Git username (default: ~/.gconfig)
--target <tgt> 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 <project> clone/pull and build
gbuild --target <t> <p> 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.<ts>` |
| `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
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
/*
* 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 {
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:<name>] 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:<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);
int config_load(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 */
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 */
+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 */
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 */
+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;
}
+78 -18
View File
@@ -1,12 +1,3 @@
/*
This file is licensed under the MIT license.
Copyright (c) Schmidt Peter Daniel 2026
*/
#include "config.h"
#include <stdio.h>
@@ -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:<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);
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;
}
+307 -88
View File
@@ -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 <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>
#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] <project>\n"
" %s (no args — opens project overview TUI)\n"
" %s --logs\n\n"
"Options:\n"
" --url <url> Override Git base URL (default: ~/.gconfig)\n"
" --user <name> Override Git username (default: ~/.gconfig)\n"
" --target <tgt> Run target directly, skip interactive picker\n"
" --url <url> Override Git base URL (default: ~/.gconfig)\n"
" --user <name> Override Git username (default: ~/.gconfig)\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"
" --no-tui With no <project>, 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 <project>' 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;
}
+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 "config.h"
#include "index.h"
#include <stdio.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);
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;
}
+229 -61
View File
@@ -10,7 +10,7 @@
#include <errno.h>
/* ================================================================
* 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;
}