Added features
Added: Project Overview TUI Build Cache / Dirty Detection Post-Build Hooks gbuild status
This commit is contained in:
@@ -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 \
|
||||
|
||||
@@ -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
Binary file not shown.
BIN
Binary file not shown.
+1352
File diff suppressed because it is too large
Load Diff
@@ -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 */
|
||||
@@ -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);
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 */
|
||||
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user