Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 939232e72e | |||
| 351ce1b06e |
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Schmidt Peter Daniel
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
CC = gcc
|
CC = gcc
|
||||||
CFLAGS = -Wall -Wextra -O2 -Iinclude
|
CFLAGS = -Wall -Wextra -O2 -Iinclude
|
||||||
LDLIBS_GBUILD = -lncurses
|
LDLIBS_GBUILD = -lncurses -lpthread
|
||||||
LDLIBS_GCONFIG =
|
LDLIBS_GCONFIG =
|
||||||
|
|
||||||
# ---- package metadata --------------------------------------
|
# ---- package metadata --------------------------------------
|
||||||
@@ -29,7 +29,10 @@ COMMON_SRCS = src/config.c
|
|||||||
|
|
||||||
GBUILD_SRCS = \
|
GBUILD_SRCS = \
|
||||||
src/gbuild.c \
|
src/gbuild.c \
|
||||||
|
src/cache.c \
|
||||||
|
src/hooks.c \
|
||||||
src/git_ops.c \
|
src/git_ops.c \
|
||||||
|
src/index.c \
|
||||||
src/logger.c \
|
src/logger.c \
|
||||||
src/make_ops.c \
|
src/make_ops.c \
|
||||||
src/tui.c \
|
src/tui.c \
|
||||||
|
|||||||
@@ -1,79 +1,75 @@
|
|||||||
# gbuild
|
# gbuild
|
||||||
|
|
||||||
A C rewrite of the original [gbuild](https://spdlab.hu/gbuild) bash tool.
|
gbuild started as a bash script to stop me from typing the same git-clone,
|
||||||
Clone, pull, pick a target, build — all from one command.
|
cd, make sequence over and over. It's since been rewritten in C and grown a
|
||||||
|
few useful features — but the idea is still the same: one command to get
|
||||||
|
the latest code and build it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## What it does
|
||||||
|
|
||||||
- **Clone or pull** — automatically clones a fresh repo on first run; pulls on subsequent runs
|
Run `gbuild` with no arguments and you get a project overview — every repo
|
||||||
- **Interactive target picker** — reads your `Makefile` and presents an ncurses TUI to select the build target; pass `--target` to skip it
|
you've built before, when you last built it, whether it passed or failed,
|
||||||
- **Timestamped log files** — every run writes a log to `~/.local/log/gbuild/` named `<timestamp>_<project>.log`
|
and the HEAD hash it was on. Pick one with J/K and hit Enter.
|
||||||
- **Log browser** — built-in two-pane TUI: file list on the left, live preview on the right; open in `less`, delete, or quit
|
|
||||||
- **Configurable** — reads `~/.gconfig`; override with `--url` and `--user` at runtime
|
Run `gbuild myproject` to go straight to a specific project. If it hasn't
|
||||||
- **`gconfig` companion** — manages `~/.gconfig` with `init`, `show`, and `help` commands
|
been cloned yet, gbuild clones it first. If it has, it pulls. Then it reads
|
||||||
|
your Makefile, shows you the targets, and you pick one. Pass `--target` if
|
||||||
|
you already know what you want and don't need the picker.
|
||||||
|
|
||||||
|
Every build gets logged to `~/.local/log/gbuild/`. Run `gbuild --logs` to
|
||||||
|
browse them — file list on the left, preview on the right, open anything in
|
||||||
|
`less` if you need the full output.
|
||||||
|
|
||||||
|
Configuration lives in `~/.gconfig`. Run `gconfig init` to create one, then
|
||||||
|
edit it with your git server URL and credentials. Token and password auth are
|
||||||
|
both supported.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
| Library | Purpose |
|
You'll need `gcc`, `make`, `git`, `less`, and the ncurses development headers.
|
||||||
|----------|------------------------------|
|
Most systems have everything except the ncurses headers — the package is
|
||||||
| ncurses | TUI for picker & log browser |
|
usually called `libncurses-dev`, `ncurses-devel`, or just `ncurses` depending
|
||||||
| git | Clone / pull |
|
on your distribution.
|
||||||
| make | Build projects |
|
|
||||||
| less | Open logs from browser |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Build
|
## Building
|
||||||
|
|
||||||
### Linux
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Install these packages with your package manager
|
|
||||||
libncurses-dev make
|
|
||||||
|
|
||||||
# Clone this repository
|
|
||||||
git clone https://github.com/jokerz/gbuild.git
|
|
||||||
|
|
||||||
# Build both binaries into bin/
|
|
||||||
make
|
make
|
||||||
|
|
||||||
# Optionally install system-wide
|
|
||||||
sudo make install
|
sudo make install
|
||||||
```
|
```
|
||||||
|
|
||||||
|
That puts `gbuild` and `gconfig` in `/usr/local/bin`. If you want them
|
||||||
|
somewhere else, `sudo make install PREFIX=/your/path`.
|
||||||
|
|
||||||
|
There's also `make release` which produces a source tarball, a stripped
|
||||||
|
binary tarball, a `.deb`, and an `.rpm` all in one go under `dist/`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick setup
|
## Setup
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# 1. Initialise ~/.gconfig with defaults
|
gconfig init # creates ~/.gconfig with defaults
|
||||||
gconfig init
|
$EDITOR ~/.gconfig # fill in your server URL and credentials
|
||||||
|
gconfig show # check it looks right
|
||||||
# 2. Fill in your values
|
gbuild # open the overview and pick something to build
|
||||||
$EDITOR ~/.gconfig
|
|
||||||
|
|
||||||
# 3. Verify
|
|
||||||
gconfig show
|
|
||||||
|
|
||||||
# 4. Build a project
|
|
||||||
gbuild myproject
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### `~/.gconfig` format
|
Your `~/.gconfig` looks like this:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
# ~/.gconfig — managed by gconfig
|
|
||||||
|
|
||||||
[git]
|
[git]
|
||||||
GIT_URL = http://localhost:3000
|
GIT_URL = http://localhost:3000
|
||||||
GIT_USER = Username
|
GIT_USER = myuser
|
||||||
|
|
||||||
[auth]
|
[auth]
|
||||||
# Use token-based OR password-based auth; leave the other blank
|
# token or password, leave the other blank
|
||||||
GIT_TOKEN =
|
GIT_TOKEN =
|
||||||
GIT_PASSWORD =
|
GIT_PASSWORD =
|
||||||
|
|
||||||
@@ -89,70 +85,50 @@ LOG_DIR = ~/.local/log/gbuild
|
|||||||
---
|
---
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
gbuild [options] <project>
|
gbuild open the project overview
|
||||||
gbuild --logs
|
gbuild <project> clone/pull and build
|
||||||
|
gbuild --target <t> <p> skip the picker, run target t
|
||||||
Options:
|
gbuild --logs open the log browser
|
||||||
--url <url> Override Git base URL (default: ~/.gconfig)
|
gbuild --no-tui print usage (useful in scripts)
|
||||||
--user <name> Override Git username (default: ~/.gconfig)
|
gbuild --url --user [username] [projectname] one-off config override
|
||||||
--target <tgt> Run target directly, skip interactive picker
|
|
||||||
--logs Open the interactive log browser
|
|
||||||
-h, --help Print this help and exit
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Examples
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# Build with defaults
|
|
||||||
gbuild myproject
|
|
||||||
|
|
||||||
# Skip the target picker
|
|
||||||
gbuild --target clean myproject
|
|
||||||
|
|
||||||
# Different server and user for this run
|
|
||||||
gbuild --url http://10.0.0.5:3000 --user alice myproject
|
|
||||||
|
|
||||||
# Browse past build logs
|
|
||||||
gbuild --logs
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## `gconfig` commands
|
## gconfig
|
||||||
|
```
|
||||||
| Command | Description |
|
gconfig init create ~/.gconfig (won't overwrite)
|
||||||
|---------------------|-------------------------------------------------------------|
|
gconfig init --force overwrite, backs up the old file first
|
||||||
| `gconfig init` | Create `~/.gconfig` with defaults (skips if already exists) |
|
gconfig show print config, masks auth fields
|
||||||
| `gconfig init --force` | Overwrite; backs up old file as `~/.gconfig.bak.<ts>` |
|
gconfig help
|
||||||
| `gconfig show` | Print current config (auth fields masked) |
|
```
|
||||||
| `gconfig help` | Print usage |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
```
|
```
|
||||||
gbuild/
|
gbuild/
|
||||||
├── Makefile
|
├── Makefile
|
||||||
├── README.md
|
├── README.md
|
||||||
├── include/
|
├── include/
|
||||||
│ ├── config.h — GConfig struct, INI load/save/init/show
|
│ ├── config.h ~/.gconfig read/write
|
||||||
│ ├── git_ops.h — clone / pull
|
│ ├── git_ops.h clone, pull, HEAD hash
|
||||||
│ ├── logger.h — timestamped coloured logging
|
│ ├── index.h ~/.gbuild_index — persistent build history
|
||||||
│ ├── make_ops.h — Makefile target parser + build runner
|
│ ├── logger.h terminal output + log files
|
||||||
│ └── tui.h — ncurses target picker + log browser
|
│ ├── make_ops.h Makefile parser and build runner
|
||||||
|
│ └── tui.h all three ncurses screens
|
||||||
|
├── pkg/
|
||||||
|
│ ├── deb/ .deb package templates
|
||||||
|
│ └── rpm/ .rpm spec template
|
||||||
└── src/
|
└── src/
|
||||||
├── config.c
|
├── gbuild.c
|
||||||
├── gbuild.c — gbuild main()
|
├── gconfig.c
|
||||||
├── gconfig.c — gconfig main()
|
├── config.c
|
||||||
├── git_ops.c
|
├── git_ops.c
|
||||||
├── logger.c
|
├── index.c
|
||||||
├── make_ops.c
|
├── logger.c
|
||||||
└── tui.c
|
├── make_ops.c
|
||||||
|
└── tui.c
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
MIT License — maintained by jokerz / spdlab.hu
|
MIT Copyright (c) Schmidt Peter Daniel 2026
|
||||||
|
|||||||
BIN
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
|
#define CFG_MAX 512
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Per-project hook override loaded from a [project:<name>] section.
|
||||||
|
* Up to CFG_MAX_PROJECT_OVERRIDES overrides are supported.
|
||||||
|
*/
|
||||||
|
#define CFG_MAX_PROJECT_OVERRIDES 64
|
||||||
|
#define CFG_PROJECT_NAME_LEN 128
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
char name[CFG_PROJECT_NAME_LEN]; /* project name, e.g. "myproject" */
|
||||||
|
char post_build_hook[CFG_MAX]; /* hook command for this project */
|
||||||
|
} GProjectOverride;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
char git_url[CFG_MAX];
|
char git_url[CFG_MAX];
|
||||||
char git_user[256];
|
char git_user[256];
|
||||||
@@ -15,8 +27,24 @@ typedef struct {
|
|||||||
char clone_dir[CFG_MAX];
|
char clone_dir[CFG_MAX];
|
||||||
bool log_enabled;
|
bool log_enabled;
|
||||||
char log_dir[CFG_MAX];
|
char log_dir[CFG_MAX];
|
||||||
|
|
||||||
|
/* Post-build hook run after every successful make invocation.
|
||||||
|
* The hook is executed with the repo directory as its CWD.
|
||||||
|
* An empty string means "no hook". */
|
||||||
|
char post_build_hook[CFG_MAX];
|
||||||
|
|
||||||
|
/* Per-project hook overrides from [project:<name>] sections. */
|
||||||
|
GProjectOverride project_overrides[CFG_MAX_PROJECT_OVERRIDES];
|
||||||
|
int project_override_count;
|
||||||
} GConfig;
|
} GConfig;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return the effective post-build hook for a given project name.
|
||||||
|
* Checks [project:<name>] overrides first; falls back to the global hook.
|
||||||
|
* Returns a pointer into cfg — do not free.
|
||||||
|
*/
|
||||||
|
const char *config_hook_for(const GConfig *cfg, const char *project);
|
||||||
|
|
||||||
void config_defaults(GConfig *cfg);
|
void config_defaults(GConfig *cfg);
|
||||||
int config_load(GConfig *cfg, const char *path);
|
int config_load(GConfig *cfg, const char *path);
|
||||||
int config_save(const GConfig *cfg, const char *path);
|
int config_save(const GConfig *cfg, const char *path);
|
||||||
|
|||||||
@@ -15,4 +15,20 @@ int git_clone(const char *base_url, const char *user,
|
|||||||
/* Pull latest in repo_path */
|
/* Pull latest in repo_path */
|
||||||
int git_pull(const char *repo_path, Logger *log);
|
int git_pull(const char *repo_path, Logger *log);
|
||||||
|
|
||||||
|
/* Read the current HEAD hash of repo_path into out (at least 41 bytes).
|
||||||
|
* Returns 0 on success, -1 on failure. */
|
||||||
|
int git_head_hash(const char *repo_path, char *out, size_t n);
|
||||||
|
|
||||||
|
/* Write the current branch name into out.
|
||||||
|
* Returns 0 on success, -1 on failure (detached HEAD writes "(detached)"). */
|
||||||
|
int git_current_branch(const char *repo_path, char *out, size_t n);
|
||||||
|
|
||||||
|
/* Returns 1 if the working tree has uncommitted changes, 0 if clean,
|
||||||
|
* -1 on error. Does NOT stage anything. */
|
||||||
|
int git_is_dirty(const char *repo_path);
|
||||||
|
|
||||||
|
/* Silently fetches from origin then returns the number of commits
|
||||||
|
* the local branch is behind its upstream, or -1 on error. */
|
||||||
|
int git_behind_count(const char *repo_path);
|
||||||
|
|
||||||
#endif /* GIT_OPS_H */
|
#endif /* GIT_OPS_H */
|
||||||
|
|||||||
@@ -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 */
|
* Keys: ↑↓ navigate Enter open in less d delete q quit */
|
||||||
void tui_log_browser(const char *log_dir);
|
void tui_log_browser(const char *log_dir);
|
||||||
|
|
||||||
|
#include "index.h"
|
||||||
|
|
||||||
|
/* Full-screen project overview.
|
||||||
|
* Lists all projects in idx with last-build status, timestamp and HEAD hash.
|
||||||
|
* On Enter, fills selected_project and returns the row index.
|
||||||
|
* Returns -1 if the user quit without selecting.
|
||||||
|
* Keys: ↑↓ navigate Enter build l log-browser q/ESC quit */
|
||||||
|
int tui_project_overview(ProjectIndex *idx,
|
||||||
|
const char *clone_dir,
|
||||||
|
const char *log_dir,
|
||||||
|
char *selected_project,
|
||||||
|
size_t sel_size);
|
||||||
|
|
||||||
#endif /* TUI_H */
|
#endif /* TUI_H */
|
||||||
|
|||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
#include "cache.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <errno.h>
|
||||||
|
|
||||||
|
/* Name of the dotfile kept inside each cloned repo. */
|
||||||
|
#define CACHE_FILENAME ".gbuild_cache"
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------- helpers */
|
||||||
|
|
||||||
|
static void cache_path(char *out, size_t n, const char *repo_path)
|
||||||
|
{
|
||||||
|
snprintf(out, n, "%s/" CACHE_FILENAME, repo_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------- public API */
|
||||||
|
|
||||||
|
int cache_read(const char *repo_path, char *out, size_t n)
|
||||||
|
{
|
||||||
|
char path[1024];
|
||||||
|
cache_path(path, sizeof(path), repo_path);
|
||||||
|
|
||||||
|
out[0] = '\0';
|
||||||
|
|
||||||
|
FILE *f = fopen(path, "r");
|
||||||
|
if (!f)
|
||||||
|
return -1; /* file doesn't exist yet — not an error, just a cache miss */
|
||||||
|
|
||||||
|
char buf[CACHE_HASH_LEN] = {0};
|
||||||
|
int ok = (fgets(buf, sizeof(buf), f) != NULL);
|
||||||
|
fclose(f);
|
||||||
|
|
||||||
|
if (!ok || buf[0] == '\0')
|
||||||
|
return -1;
|
||||||
|
|
||||||
|
/* strip trailing newline */
|
||||||
|
size_t l = strlen(buf);
|
||||||
|
if (l > 0 && buf[l - 1] == '\n')
|
||||||
|
buf[--l] = '\0';
|
||||||
|
|
||||||
|
if (l == 0)
|
||||||
|
return -1;
|
||||||
|
|
||||||
|
strncpy(out, buf, n - 1);
|
||||||
|
out[n - 1] = '\0';
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int cache_write(const char *repo_path, const char *hash)
|
||||||
|
{
|
||||||
|
char path[1024];
|
||||||
|
cache_path(path, sizeof(path), repo_path);
|
||||||
|
|
||||||
|
FILE *f = fopen(path, "w");
|
||||||
|
if (!f) {
|
||||||
|
fprintf(stderr, "[WARN ] cache_write: cannot open %s: %s\n",
|
||||||
|
path, strerror(errno));
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
fprintf(f, "%s\n", hash);
|
||||||
|
fclose(f);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
+75
-15
@@ -1,12 +1,3 @@
|
|||||||
/*
|
|
||||||
|
|
||||||
This file is licensed under the MIT license.
|
|
||||||
|
|
||||||
Copyright (c) Schmidt Peter Daniel 2026
|
|
||||||
|
|
||||||
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
|
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
@@ -51,6 +42,9 @@ void config_defaults(GConfig *cfg)
|
|||||||
if (!home) home = "/tmp";
|
if (!home) home = "/tmp";
|
||||||
snprintf(cfg->clone_dir, CFG_MAX, "%s/projects", home);
|
snprintf(cfg->clone_dir, CFG_MAX, "%s/projects", home);
|
||||||
snprintf(cfg->log_dir, CFG_MAX, "%s/.local/log/gbuild", home);
|
snprintf(cfg->log_dir, CFG_MAX, "%s/.local/log/gbuild", home);
|
||||||
|
|
||||||
|
cfg->post_build_hook[0] = '\0';
|
||||||
|
cfg->project_override_count = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ loader */
|
/* ------------------------------------------------------------------ loader */
|
||||||
@@ -76,13 +70,13 @@ int config_load(GConfig *cfg, const char *path)
|
|||||||
if (!f) return -1;
|
if (!f) return -1;
|
||||||
|
|
||||||
char line[512];
|
char line[512];
|
||||||
char section[64] = "";
|
char section[128] = ""; /* e.g. "build", "project:myproject" */
|
||||||
|
|
||||||
while (fgets(line, sizeof(line), f)) {
|
while (fgets(line, sizeof(line), f)) {
|
||||||
strip(line);
|
strip(line);
|
||||||
if (line[0] == '\0' || line[0] == '#') continue;
|
if (line[0] == '\0' || line[0] == '#') continue;
|
||||||
|
|
||||||
/* section header */
|
/* ---- section header ---- */
|
||||||
if (line[0] == '[') {
|
if (line[0] == '[') {
|
||||||
char *end = strchr(line, ']');
|
char *end = strchr(line, ']');
|
||||||
if (end) {
|
if (end) {
|
||||||
@@ -92,7 +86,7 @@ int config_load(GConfig *cfg, const char *path)
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* key = value */
|
/* ---- key = value ---- */
|
||||||
char *eq = strchr(line, '=');
|
char *eq = strchr(line, '=');
|
||||||
if (!eq) continue;
|
if (!eq) continue;
|
||||||
*eq = '\0';
|
*eq = '\0';
|
||||||
@@ -104,6 +98,30 @@ int config_load(GConfig *cfg, const char *path)
|
|||||||
char expanded[CFG_MAX];
|
char expanded[CFG_MAX];
|
||||||
expand_tilde(val, expanded, sizeof(expanded));
|
expand_tilde(val, expanded, sizeof(expanded));
|
||||||
|
|
||||||
|
/* ---- [project:<name>] overrides ---- */
|
||||||
|
if (strncmp(section, "project:", 8) == 0) {
|
||||||
|
const char *proj_name = section + 8;
|
||||||
|
if (proj_name[0] == '\0') continue;
|
||||||
|
|
||||||
|
/* find or create an override slot for this project */
|
||||||
|
GProjectOverride *ov = NULL;
|
||||||
|
for (int i = 0; i < cfg->project_override_count; i++) {
|
||||||
|
if (!strcmp(cfg->project_overrides[i].name, proj_name)) {
|
||||||
|
ov = &cfg->project_overrides[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ov && cfg->project_override_count < CFG_MAX_PROJECT_OVERRIDES) {
|
||||||
|
ov = &cfg->project_overrides[cfg->project_override_count++];
|
||||||
|
memset(ov, 0, sizeof(*ov));
|
||||||
|
strncpy(ov->name, proj_name, CFG_PROJECT_NAME_LEN - 1);
|
||||||
|
}
|
||||||
|
if (ov && !strcmp(key, "POST_BUILD_HOOK"))
|
||||||
|
strncpy(ov->post_build_hook, expanded, CFG_MAX - 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- global keys ---- */
|
||||||
if (!strcmp(key, "GIT_URL")) strncpy(cfg->git_url, expanded, CFG_MAX - 1);
|
if (!strcmp(key, "GIT_URL")) strncpy(cfg->git_url, expanded, CFG_MAX - 1);
|
||||||
else if (!strcmp(key, "GIT_USER")) strncpy(cfg->git_user, expanded, sizeof(cfg->git_user) - 1);
|
else if (!strcmp(key, "GIT_USER")) strncpy(cfg->git_user, expanded, sizeof(cfg->git_user) - 1);
|
||||||
else if (!strcmp(key, "GIT_TOKEN")) strncpy(cfg->git_token, expanded, sizeof(cfg->git_token) - 1);
|
else if (!strcmp(key, "GIT_TOKEN")) strncpy(cfg->git_token, expanded, sizeof(cfg->git_token) - 1);
|
||||||
@@ -113,7 +131,7 @@ int config_load(GConfig *cfg, const char *path)
|
|||||||
else if (!strcmp(key, "LOG_DIR")) strncpy(cfg->log_dir, expanded, CFG_MAX - 1);
|
else if (!strcmp(key, "LOG_DIR")) strncpy(cfg->log_dir, expanded, CFG_MAX - 1);
|
||||||
else if (!strcmp(key, "LOG_ENABLED")) cfg->log_enabled = (strcmp(expanded, "false") != 0 &&
|
else if (!strcmp(key, "LOG_ENABLED")) cfg->log_enabled = (strcmp(expanded, "false") != 0 &&
|
||||||
strcmp(expanded, "0") != 0);
|
strcmp(expanded, "0") != 0);
|
||||||
(void)section;
|
else if (!strcmp(key, "POST_BUILD_HOOK")) strncpy(cfg->post_build_hook, expanded, CFG_MAX - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
fclose(f);
|
fclose(f);
|
||||||
@@ -138,7 +156,10 @@ int config_save(const GConfig *cfg, const char *path)
|
|||||||
"GIT_PASSWORD = %s\n\n"
|
"GIT_PASSWORD = %s\n\n"
|
||||||
"[build]\n"
|
"[build]\n"
|
||||||
"DEFAULT_TARGET = %s\n"
|
"DEFAULT_TARGET = %s\n"
|
||||||
"CLONE_DIR = %s\n\n"
|
"CLONE_DIR = %s\n"
|
||||||
|
"# POST_BUILD_HOOK runs after every successful build (leave blank for none)\n"
|
||||||
|
"# Example: POST_BUILD_HOOK = systemctl restart myservice\n"
|
||||||
|
"POST_BUILD_HOOK = %s\n\n"
|
||||||
"[log]\n"
|
"[log]\n"
|
||||||
"LOG_ENABLED = %s\n"
|
"LOG_ENABLED = %s\n"
|
||||||
"LOG_DIR = %s\n",
|
"LOG_DIR = %s\n",
|
||||||
@@ -148,9 +169,21 @@ int config_save(const GConfig *cfg, const char *path)
|
|||||||
cfg->git_password,
|
cfg->git_password,
|
||||||
cfg->default_target,
|
cfg->default_target,
|
||||||
cfg->clone_dir,
|
cfg->clone_dir,
|
||||||
|
cfg->post_build_hook,
|
||||||
cfg->log_enabled ? "true" : "false",
|
cfg->log_enabled ? "true" : "false",
|
||||||
cfg->log_dir);
|
cfg->log_dir);
|
||||||
|
|
||||||
|
/* Per-project overrides: only write sections that have a hook set */
|
||||||
|
for (int i = 0; i < cfg->project_override_count; i++) {
|
||||||
|
const GProjectOverride *ov = &cfg->project_overrides[i];
|
||||||
|
if (ov->post_build_hook[0] == '\0') continue;
|
||||||
|
fprintf(f,
|
||||||
|
"\n[project:%s]\n"
|
||||||
|
"POST_BUILD_HOOK = %s\n",
|
||||||
|
ov->name,
|
||||||
|
ov->post_build_hook);
|
||||||
|
}
|
||||||
|
|
||||||
fclose(f);
|
fclose(f);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -197,8 +230,35 @@ void config_show(const GConfig *cfg)
|
|||||||
printf("\n [build]\n");
|
printf("\n [build]\n");
|
||||||
printf(" DEFAULT_TARGET = %s\n", cfg->default_target[0] ? cfg->default_target : "(not set)");
|
printf(" DEFAULT_TARGET = %s\n", cfg->default_target[0] ? cfg->default_target : "(not set)");
|
||||||
printf(" CLONE_DIR = %s\n", cfg->clone_dir);
|
printf(" CLONE_DIR = %s\n", cfg->clone_dir);
|
||||||
|
printf(" POST_BUILD_HOOK = %s\n", cfg->post_build_hook[0] ? cfg->post_build_hook : "(not set)");
|
||||||
|
|
||||||
printf("\n [log]\n");
|
printf("\n [log]\n");
|
||||||
printf(" LOG_ENABLED = %s\n", cfg->log_enabled ? "true" : "false");
|
printf(" LOG_ENABLED = %s\n", cfg->log_enabled ? "true" : "false");
|
||||||
printf(" LOG_DIR = %s\n\n", cfg->log_dir);
|
printf(" LOG_DIR = %s\n", cfg->log_dir);
|
||||||
|
|
||||||
|
if (cfg->project_override_count > 0) {
|
||||||
|
printf("\n Per-project hook overrides:\n");
|
||||||
|
for (int i = 0; i < cfg->project_override_count; i++) {
|
||||||
|
const GProjectOverride *ov = &cfg->project_overrides[i];
|
||||||
|
if (ov->post_build_hook[0] == '\0') continue;
|
||||||
|
printf(" [project:%s]\n", ov->name);
|
||||||
|
printf(" POST_BUILD_HOOK = %s\n", ov->post_build_hook);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
printf("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ hook lookup */
|
||||||
|
|
||||||
|
const char *config_hook_for(const GConfig *cfg, const char *project)
|
||||||
|
{
|
||||||
|
/* Check per-project overrides first */
|
||||||
|
for (int i = 0; i < cfg->project_override_count; i++) {
|
||||||
|
const GProjectOverride *ov = &cfg->project_overrides[i];
|
||||||
|
if (!strcmp(ov->name, project) && ov->post_build_hook[0] != '\0')
|
||||||
|
return ov->post_build_hook;
|
||||||
|
}
|
||||||
|
/* Fall back to global hook (may also be empty string = no hook) */
|
||||||
|
return cfg->post_build_hook;
|
||||||
}
|
}
|
||||||
|
|||||||
+302
-83
@@ -1,5 +1,8 @@
|
|||||||
|
#include "cache.h"
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
|
#include "hooks.h"
|
||||||
#include "git_ops.h"
|
#include "git_ops.h"
|
||||||
|
#include "index.h"
|
||||||
#include "logger.h"
|
#include "logger.h"
|
||||||
#include "make_ops.h"
|
#include "make_ops.h"
|
||||||
#include "tui.h"
|
#include "tui.h"
|
||||||
@@ -8,6 +11,7 @@
|
|||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
#include <sys/stat.h>
|
#include <sys/stat.h>
|
||||||
|
#include <time.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
|
|
||||||
#define VERSION "1.0.0"
|
#define VERSION "1.0.0"
|
||||||
@@ -20,20 +24,272 @@ static void print_usage(const char *prog)
|
|||||||
"gbuild %s — A build tool for your Linux distro\n\n"
|
"gbuild %s — A build tool for your Linux distro\n\n"
|
||||||
"Usage:\n"
|
"Usage:\n"
|
||||||
" %s [options] <project>\n"
|
" %s [options] <project>\n"
|
||||||
|
" %s (no args — opens project overview TUI)\n"
|
||||||
" %s --logs\n\n"
|
" %s --logs\n\n"
|
||||||
"Options:\n"
|
"Options:\n"
|
||||||
" --url <url> Override Git base URL (default: ~/.gconfig)\n"
|
" --url <url> Override Git base URL (default: ~/.gconfig)\n"
|
||||||
" --user <name> Override Git username (default: ~/.gconfig)\n"
|
" --user <name> Override Git username (default: ~/.gconfig)\n"
|
||||||
" --target <tgt> Run target directly, skip interactive picker\n"
|
" --target <tgt> Run target directly, skip interactive picker\n"
|
||||||
|
" --force Force build even if HEAD hash is unchanged\n"
|
||||||
|
" --status Print status table for all cloned projects\n"
|
||||||
|
" --no-color Disable ANSI colour in --status output\n"
|
||||||
" --logs Open the interactive log browser\n"
|
" --logs Open the interactive log browser\n"
|
||||||
|
" --no-tui With no <project>, print usage instead of opening TUI\n"
|
||||||
" -h, --help Print this help and exit\n\n"
|
" -h, --help Print this help and exit\n\n"
|
||||||
"Examples:\n"
|
"Examples:\n"
|
||||||
|
" gbuild (overview TUI — pick and build)\n"
|
||||||
" gbuild myproject\n"
|
" gbuild myproject\n"
|
||||||
|
" gbuild --force myproject\n"
|
||||||
|
" gbuild --status\n"
|
||||||
|
" gbuild --status --no-color | grep dirty\n"
|
||||||
" gbuild --target clean myproject\n"
|
" gbuild --target clean myproject\n"
|
||||||
" gbuild --url http://10.0.0.5:3000 --user alice myproject\n"
|
" gbuild --url http://10.0.0.5:3000 --user alice myproject\n"
|
||||||
" gbuild --logs\n\n"
|
" gbuild --logs\n\n"
|
||||||
"Configuration is read from ~/.gconfig. Run 'gconfig init' to set it up.\n",
|
"Configuration is read from ~/.gconfig. Run 'gconfig init' to set it up.\n",
|
||||||
VERSION, prog, prog);
|
VERSION, prog, prog, prog);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------- status table */
|
||||||
|
|
||||||
|
/* ANSI colour codes (empty strings when --no-color) */
|
||||||
|
#define COL_RED "\033[31m"
|
||||||
|
#define COL_GREEN "\033[32m"
|
||||||
|
#define COL_YELLOW "\033[33m"
|
||||||
|
#define COL_BOLD "\033[1m"
|
||||||
|
#define COL_RESET "\033[0m"
|
||||||
|
|
||||||
|
static void print_status_table(const char *clone_dir,
|
||||||
|
const ProjectIndex *idx,
|
||||||
|
int no_color)
|
||||||
|
{
|
||||||
|
StatusResult results[IDX_MAX_PROJECTS];
|
||||||
|
int n = project_scan_all(clone_dir, idx, results, IDX_MAX_PROJECTS);
|
||||||
|
|
||||||
|
if (n == 0) {
|
||||||
|
printf("No git repositories found in %s\n", clone_dir);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *c_red = no_color ? "" : COL_RED;
|
||||||
|
const char *c_green = no_color ? "" : COL_GREEN;
|
||||||
|
const char *c_yellow = no_color ? "" : COL_YELLOW;
|
||||||
|
const char *c_bold = no_color ? "" : COL_BOLD;
|
||||||
|
const char *c_reset = no_color ? "" : COL_RESET;
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
printf("%s%-24s %-20s %-7s %-5s %-14s %s%s\n",
|
||||||
|
c_bold,
|
||||||
|
"PROJECT", "BRANCH", "BEHIND", "DIRTY", "LAST BUILD", "BUILD RC",
|
||||||
|
c_reset);
|
||||||
|
printf("%-24s %-20s %-7s %-5s %-14s %s\n",
|
||||||
|
"------------------------",
|
||||||
|
"--------------------",
|
||||||
|
"-------",
|
||||||
|
"-----",
|
||||||
|
"--------------",
|
||||||
|
"--------");
|
||||||
|
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
StatusResult *r = &results[i];
|
||||||
|
|
||||||
|
/* ---- colour for project name / branch ---- */
|
||||||
|
const char *nc = c_green; /* default: clean */
|
||||||
|
if (r->dirty == 1 || r->behind > 0)
|
||||||
|
nc = c_yellow;
|
||||||
|
|
||||||
|
/* ---- behind field ---- */
|
||||||
|
char behind_s[64];
|
||||||
|
if (r->behind < 0)
|
||||||
|
snprintf(behind_s, sizeof(behind_s), "n/a");
|
||||||
|
else if (r->behind == 0)
|
||||||
|
snprintf(behind_s, sizeof(behind_s), "0");
|
||||||
|
else
|
||||||
|
snprintf(behind_s, sizeof(behind_s), "%s%d%s",
|
||||||
|
c_yellow, r->behind, c_reset);
|
||||||
|
|
||||||
|
/* ---- dirty field ---- */
|
||||||
|
const char *dirty_s;
|
||||||
|
if (r->dirty < 0)
|
||||||
|
dirty_s = "n/a";
|
||||||
|
else if (r->dirty == 0)
|
||||||
|
dirty_s = "no";
|
||||||
|
else
|
||||||
|
dirty_s = no_color ? "YES" : COL_YELLOW "yes" COL_RESET;
|
||||||
|
|
||||||
|
/* ---- build status ---- */
|
||||||
|
char ts_s[20] = "never";
|
||||||
|
if (r->last_build_ts > 0) {
|
||||||
|
struct tm *tm = localtime(&r->last_build_ts);
|
||||||
|
strftime(ts_s, sizeof(ts_s), "%Y-%m-%d %H:%M", tm);
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *bc;
|
||||||
|
char rc_s[64];
|
||||||
|
if (r->last_build_rc < 0) {
|
||||||
|
bc = c_red;
|
||||||
|
snprintf(rc_s, sizeof(rc_s), "never");
|
||||||
|
} else if (r->last_build_rc == 0) {
|
||||||
|
bc = c_green;
|
||||||
|
snprintf(rc_s, sizeof(rc_s), "ok (0)");
|
||||||
|
} else {
|
||||||
|
bc = c_red;
|
||||||
|
snprintf(rc_s, sizeof(rc_s), "fail (%d)", r->last_build_rc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Truncate branch if too long */
|
||||||
|
char branch_disp[21];
|
||||||
|
snprintf(branch_disp, sizeof(branch_disp), "%s", r->branch);
|
||||||
|
|
||||||
|
printf("%s%-24s%s %s%-20s%s %-7s %-5s %-14s %s%s%s\n",
|
||||||
|
nc, r->name, c_reset,
|
||||||
|
nc, branch_disp, c_reset,
|
||||||
|
behind_s,
|
||||||
|
dirty_s,
|
||||||
|
ts_s,
|
||||||
|
bc, rc_s, c_reset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------- build one project */
|
||||||
|
|
||||||
|
static int build_project(const char *project,
|
||||||
|
const char *arg_target,
|
||||||
|
GConfig *cfg,
|
||||||
|
const char *clone_dir,
|
||||||
|
const char *log_dir,
|
||||||
|
ProjectIndex *idx,
|
||||||
|
int force)
|
||||||
|
{
|
||||||
|
/* ---- open logger ---- */
|
||||||
|
Logger log;
|
||||||
|
memset(&log, 0, sizeof(log));
|
||||||
|
if (cfg->log_enabled) {
|
||||||
|
if (logger_open(&log, log_dir, project) == 0)
|
||||||
|
logger_info(&log, "Logging to: %s", log.path);
|
||||||
|
else
|
||||||
|
fprintf(stderr, "[WARN ] Could not open log file in %s\n", log_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger_info(&log, "gbuild started for project: %s", project);
|
||||||
|
|
||||||
|
/* ---- clone or pull ---- */
|
||||||
|
char repo_path[CFG_MAX * 2];
|
||||||
|
snprintf(repo_path, sizeof(repo_path), "%s/%s", clone_dir, project);
|
||||||
|
|
||||||
|
if (git_repo_exists(clone_dir, project)) {
|
||||||
|
logger_info(&log, "Repo already cloned — pulling latest changes ...");
|
||||||
|
int rc = git_pull(repo_path, &log);
|
||||||
|
if (rc != 0) {
|
||||||
|
logger_error(&log, "git pull failed (exit %d).", rc);
|
||||||
|
logger_close(&log);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
logger_ok(&log, "Repository updated.");
|
||||||
|
} else {
|
||||||
|
logger_info(&log, "Cloning %s/%s/%s ...",
|
||||||
|
cfg->git_url, cfg->git_user, project);
|
||||||
|
int rc = git_clone(cfg->git_url, cfg->git_user,
|
||||||
|
cfg->git_token, cfg->git_password,
|
||||||
|
project, clone_dir, &log);
|
||||||
|
if (rc != 0) {
|
||||||
|
logger_error(&log, "git clone failed (exit %d).", rc);
|
||||||
|
logger_close(&log);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
logger_ok(&log, "Repository cloned.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- read current HEAD hash ---- */
|
||||||
|
char current_hash[IDX_HASH_LEN] = {0};
|
||||||
|
int have_hash = (git_head_hash(repo_path, current_hash, sizeof(current_hash)) == 0);
|
||||||
|
|
||||||
|
/* ---- dirty detection: skip build if HEAD unchanged ---- */
|
||||||
|
if (!force && have_hash) {
|
||||||
|
char cached_hash[CACHE_HASH_LEN] = {0};
|
||||||
|
if (cache_read(repo_path, cached_hash, sizeof(cached_hash)) == 0 &&
|
||||||
|
cached_hash[0] != '\0' &&
|
||||||
|
strncmp(current_hash, cached_hash, IDX_HASH_LEN - 1) == 0)
|
||||||
|
{
|
||||||
|
logger_ok(&log,
|
||||||
|
"Build skipped — repo is unchanged since last successful build "
|
||||||
|
"(HEAD: %.12s). Use --force to build anyway.",
|
||||||
|
current_hash);
|
||||||
|
logger_close(&log);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- record HEAD hash in index ---- */
|
||||||
|
ProjectRecord *rec = index_upsert(idx, project);
|
||||||
|
if (rec && have_hash)
|
||||||
|
strncpy(rec->last_head_hash, current_hash, IDX_HASH_LEN - 1);
|
||||||
|
|
||||||
|
/* ---- resolve make target ---- */
|
||||||
|
char target[TARGET_NAME];
|
||||||
|
memset(target, 0, sizeof(target));
|
||||||
|
|
||||||
|
if (arg_target) {
|
||||||
|
strncpy(target, arg_target, sizeof(target) - 1);
|
||||||
|
logger_info(&log, "Selected target: %s", target);
|
||||||
|
} else if (cfg->default_target[0]) {
|
||||||
|
strncpy(target, cfg->default_target, sizeof(target) - 1);
|
||||||
|
logger_info(&log, "Using default target: %s", target);
|
||||||
|
} else {
|
||||||
|
MakeTargets targets;
|
||||||
|
if (make_parse_targets(repo_path, &targets) != 0) {
|
||||||
|
logger_warn(&log,
|
||||||
|
"No targets found in Makefile — running 'make' with no target.");
|
||||||
|
target[0] = '\0';
|
||||||
|
} else {
|
||||||
|
if (tui_pick_target(&targets, target, sizeof(target)) < 0) {
|
||||||
|
logger_warn(&log, "No target selected — aborting.");
|
||||||
|
logger_close(&log);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger_info(&log, "Selected target: %s",
|
||||||
|
target[0] ? target : "(default)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- build ---- */
|
||||||
|
logger_info(&log, "Building '%s' target '%s' ...",
|
||||||
|
project, target[0] ? target : "(default)");
|
||||||
|
|
||||||
|
int rc = make_build(repo_path, target, &log);
|
||||||
|
|
||||||
|
/* ---- update index ---- */
|
||||||
|
if (rec) {
|
||||||
|
rec->last_build_rc = rc;
|
||||||
|
rec->last_build_ts = time(NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rc != 0) {
|
||||||
|
logger_error(&log, "Build failed (exit %d).", rc);
|
||||||
|
logger_close(&log);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- write cache only on success ---- */
|
||||||
|
if (have_hash)
|
||||||
|
cache_write(repo_path, current_hash);
|
||||||
|
|
||||||
|
/* ---- post-build hook ---- */
|
||||||
|
const char *hook = config_hook_for(cfg, project);
|
||||||
|
if (hook && hook[0] != '\0') {
|
||||||
|
int hook_rc = hook_run(hook, repo_path, &log);
|
||||||
|
if (hook_rc != 0) {
|
||||||
|
logger_error(&log,
|
||||||
|
"Post-build hook exited with status %d "
|
||||||
|
"(build itself succeeded).", hook_rc);
|
||||||
|
logger_close(&log);
|
||||||
|
return 2; /* 2 = hook failure, distinct from build failure (1) */
|
||||||
|
}
|
||||||
|
logger_ok(&log, "Post-build hook completed successfully.");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger_ok(&log, "gbuild complete.");
|
||||||
|
logger_close(&log);
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ----------------------------------------------------------------- main */
|
/* ----------------------------------------------------------------- main */
|
||||||
@@ -46,6 +302,10 @@ int main(int argc, char *argv[])
|
|||||||
const char *arg_target = NULL;
|
const char *arg_target = NULL;
|
||||||
const char *arg_project = NULL;
|
const char *arg_project = NULL;
|
||||||
int flag_logs = 0;
|
int flag_logs = 0;
|
||||||
|
int flag_no_tui = 0;
|
||||||
|
int flag_force = 0;
|
||||||
|
int flag_status = 0;
|
||||||
|
int flag_no_color = 0;
|
||||||
|
|
||||||
for (int i = 1; i < argc; i++) {
|
for (int i = 1; i < argc; i++) {
|
||||||
if (!strcmp(argv[i], "-h") || !strcmp(argv[i], "--help")) {
|
if (!strcmp(argv[i], "-h") || !strcmp(argv[i], "--help")) {
|
||||||
@@ -60,8 +320,16 @@ int main(int argc, char *argv[])
|
|||||||
} else if (!strcmp(argv[i], "--target")) {
|
} else if (!strcmp(argv[i], "--target")) {
|
||||||
if (++i >= argc) { fprintf(stderr, "error: --target requires a value\n"); return 1; }
|
if (++i >= argc) { fprintf(stderr, "error: --target requires a value\n"); return 1; }
|
||||||
arg_target = argv[i];
|
arg_target = argv[i];
|
||||||
|
} else if (!strcmp(argv[i], "--force")) {
|
||||||
|
flag_force = 1;
|
||||||
|
} else if (!strcmp(argv[i], "--status")) {
|
||||||
|
flag_status = 1;
|
||||||
|
} else if (!strcmp(argv[i], "--no-color")) {
|
||||||
|
flag_no_color = 1;
|
||||||
} else if (!strcmp(argv[i], "--logs")) {
|
} else if (!strcmp(argv[i], "--logs")) {
|
||||||
flag_logs = 1;
|
flag_logs = 1;
|
||||||
|
} else if (!strcmp(argv[i], "--no-tui")) {
|
||||||
|
flag_no_tui = 1;
|
||||||
} else if (argv[i][0] == '-') {
|
} else if (argv[i][0] == '-') {
|
||||||
fprintf(stderr, "error: unknown flag '%s'\n", argv[i]);
|
fprintf(stderr, "error: unknown flag '%s'\n", argv[i]);
|
||||||
return 1;
|
return 1;
|
||||||
@@ -86,109 +354,60 @@ int main(int argc, char *argv[])
|
|||||||
config_defaults(&cfg);
|
config_defaults(&cfg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* apply overrides */
|
|
||||||
if (arg_url) strncpy(cfg.git_url, arg_url, CFG_MAX - 1);
|
if (arg_url) strncpy(cfg.git_url, arg_url, CFG_MAX - 1);
|
||||||
if (arg_user) strncpy(cfg.git_user, arg_user, sizeof(cfg.git_user) - 1);
|
if (arg_user) strncpy(cfg.git_user, arg_user, sizeof(cfg.git_user) - 1);
|
||||||
|
|
||||||
/* expand paths that might contain ~ */
|
|
||||||
char clone_dir[CFG_MAX], log_dir[CFG_MAX];
|
char clone_dir[CFG_MAX], log_dir[CFG_MAX];
|
||||||
expand_tilde(cfg.clone_dir, clone_dir, sizeof(clone_dir));
|
expand_tilde(cfg.clone_dir, clone_dir, sizeof(clone_dir));
|
||||||
expand_tilde(cfg.log_dir, log_dir, sizeof(log_dir));
|
expand_tilde(cfg.log_dir, log_dir, sizeof(log_dir));
|
||||||
|
|
||||||
|
/* ---- load / scan project index ---- */
|
||||||
|
ProjectIndex idx;
|
||||||
|
char idx_path[512];
|
||||||
|
index_get_path(idx_path, sizeof(idx_path));
|
||||||
|
index_load(&idx, idx_path); /* ok if file doesn't exist yet */
|
||||||
|
index_scan(&idx, clone_dir); /* pick up any new repos on disk */
|
||||||
|
|
||||||
|
/* ---- status table mode ---- */
|
||||||
|
if (flag_status) {
|
||||||
|
print_status_table(clone_dir, &idx, flag_no_color);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- log browser mode ---- */
|
/* ---- log browser mode ---- */
|
||||||
if (flag_logs) {
|
if (flag_logs) {
|
||||||
tui_log_browser(log_dir);
|
tui_log_browser(log_dir);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- need a project name ---- */
|
/* ---- no project given: open overview TUI or print usage ---- */
|
||||||
if (!arg_project) {
|
if (!arg_project) {
|
||||||
fprintf(stderr, "error: no project specified.\n\n");
|
if (flag_no_tui || idx.count == 0) {
|
||||||
print_usage(argv[0]);
|
if (idx.count == 0 && !flag_no_tui)
|
||||||
return 1;
|
fprintf(stderr,
|
||||||
}
|
"No projects found in %s.\n"
|
||||||
|
"Run 'gbuild <project>' to clone and build one first.\n",
|
||||||
/* ---- open logger ---- */
|
clone_dir);
|
||||||
Logger log;
|
|
||||||
memset(&log, 0, sizeof(log));
|
|
||||||
if (cfg.log_enabled) {
|
|
||||||
if (logger_open(&log, log_dir, arg_project) == 0)
|
|
||||||
logger_info(&log, "Logging to: %s", log.path);
|
|
||||||
else
|
else
|
||||||
fprintf(stderr, "[WARN ] Could not open log file in %s\n", log_dir);
|
print_usage(argv[0]);
|
||||||
|
return (idx.count == 0) ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger_info(&log, "gbuild started for project: %s", arg_project);
|
static char chosen[IDX_NAME_LEN];
|
||||||
|
memset(chosen, 0, sizeof(chosen));
|
||||||
|
if (tui_project_overview(&idx, clone_dir, log_dir,
|
||||||
|
chosen, sizeof(chosen)) < 0)
|
||||||
|
return 0; /* user quit without picking */
|
||||||
|
|
||||||
/* ---- clone or pull ---- */
|
arg_project = chosen;
|
||||||
char repo_path[CFG_MAX * 2];
|
|
||||||
snprintf(repo_path, sizeof(repo_path), "%s/%s", clone_dir, arg_project);
|
|
||||||
|
|
||||||
if (git_repo_exists(clone_dir, arg_project)) {
|
|
||||||
logger_info(&log, "Repo already cloned — pulling latest changes ...");
|
|
||||||
int rc = git_pull(repo_path, &log);
|
|
||||||
if (rc != 0) {
|
|
||||||
logger_error(&log, "git pull failed (exit %d).", rc);
|
|
||||||
logger_close(&log);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
logger_ok(&log, "Repository updated.");
|
|
||||||
} else {
|
|
||||||
logger_info(&log, "Cloning %s/%s/%s ...",
|
|
||||||
cfg.git_url, cfg.git_user, arg_project);
|
|
||||||
int rc = git_clone(cfg.git_url, cfg.git_user,
|
|
||||||
cfg.git_token, cfg.git_password,
|
|
||||||
arg_project, clone_dir, &log);
|
|
||||||
if (rc != 0) {
|
|
||||||
logger_error(&log, "git clone failed (exit %d).", rc);
|
|
||||||
logger_close(&log);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
logger_ok(&log, "Repository cloned.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- resolve make target ---- */
|
|
||||||
char target[TARGET_NAME];
|
|
||||||
memset(target, 0, sizeof(target));
|
|
||||||
|
|
||||||
if (arg_target) {
|
|
||||||
/* explicit --target flag */
|
|
||||||
strncpy(target, arg_target, sizeof(target) - 1);
|
|
||||||
logger_info(&log, "Selected target: %s", target);
|
|
||||||
} else if (cfg.default_target[0]) {
|
|
||||||
/* default from config */
|
|
||||||
strncpy(target, cfg.default_target, sizeof(target) - 1);
|
|
||||||
logger_info(&log, "Using default target: %s", target);
|
|
||||||
} else {
|
|
||||||
/* interactive TUI picker */
|
|
||||||
MakeTargets targets;
|
|
||||||
if (make_parse_targets(repo_path, &targets) != 0) {
|
|
||||||
logger_warn(&log,
|
|
||||||
"No targets found in Makefile — running 'make' with no target.");
|
|
||||||
target[0] = '\0';
|
|
||||||
} else {
|
|
||||||
if (tui_pick_target(&targets, target, sizeof(target)) < 0) {
|
|
||||||
logger_warn(&log, "No target selected — aborting.");
|
|
||||||
logger_close(&log);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logger_info(&log, "Selected target: %s",
|
|
||||||
target[0] ? target : "(default)");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- build ---- */
|
/* ---- build ---- */
|
||||||
logger_info(&log, "gbuild: building '%s' target '%s' ...",
|
int rc = build_project(arg_project, arg_target,
|
||||||
arg_project, target[0] ? target : "(default)");
|
&cfg, clone_dir, log_dir, &idx, flag_force);
|
||||||
|
|
||||||
int rc = make_build(repo_path, target, &log);
|
/* ---- persist updated index ---- */
|
||||||
if (rc != 0) {
|
index_save(&idx, idx_path);
|
||||||
logger_error(&log, "Build failed (exit %d).", rc);
|
|
||||||
logger_close(&log);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger_ok(&log, "gbuild complete.");
|
return rc;
|
||||||
logger_close(&log);
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+110
-8
@@ -1,13 +1,6 @@
|
|||||||
/*
|
|
||||||
|
|
||||||
This file is licensed under the MIT license.
|
|
||||||
Copyright (c) Schmidt Peter Daniel 2026.
|
|
||||||
|
|
||||||
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "git_ops.h"
|
#include "git_ops.h"
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
|
#include "index.h"
|
||||||
|
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
@@ -131,3 +124,112 @@ int git_pull(const char *repo_path, Logger *log)
|
|||||||
"git -C \"%s\" pull 2>&1", repo_path);
|
"git -C \"%s\" pull 2>&1", repo_path);
|
||||||
return run_stream(cmd, log);
|
return run_stream(cmd, log);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int git_head_hash(const char *repo_path, char *out, size_t n)
|
||||||
|
{
|
||||||
|
char cmd[IDX_PATH_LEN + 64];
|
||||||
|
snprintf(cmd, sizeof(cmd),
|
||||||
|
"git -C \"%s\" rev-parse HEAD 2>/dev/null", repo_path);
|
||||||
|
|
||||||
|
FILE *p = popen(cmd, "r");
|
||||||
|
if (!p) return -1;
|
||||||
|
|
||||||
|
char buf[64] = {0};
|
||||||
|
int ok = (fgets(buf, sizeof(buf), p) != NULL);
|
||||||
|
pclose(p);
|
||||||
|
|
||||||
|
if (!ok || buf[0] == '\0') return -1;
|
||||||
|
|
||||||
|
/* strip newline */
|
||||||
|
size_t l = strlen(buf);
|
||||||
|
if (l > 0 && buf[l-1] == '\n') buf[--l] = '\0';
|
||||||
|
|
||||||
|
strncpy(out, buf, n - 1);
|
||||||
|
out[n - 1] = '\0';
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------- status helpers */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Helper: run a git command silently (stderr→/dev/null), capture first line
|
||||||
|
* of stdout into buf. Returns 0 on success, -1 on error / non-zero exit.
|
||||||
|
*/
|
||||||
|
static int git_capture(const char *cmd, char *buf, size_t n)
|
||||||
|
{
|
||||||
|
FILE *p = popen(cmd, "r");
|
||||||
|
if (!p) return -1;
|
||||||
|
|
||||||
|
char tmp[512] = {0};
|
||||||
|
int got = (fgets(tmp, sizeof(tmp), p) != NULL);
|
||||||
|
int st = pclose(p);
|
||||||
|
|
||||||
|
if (!got || WEXITSTATUS(st) != 0) return -1;
|
||||||
|
|
||||||
|
/* strip newline */
|
||||||
|
size_t l = strlen(tmp);
|
||||||
|
if (l > 0 && tmp[l-1] == '\n') tmp[--l] = '\0';
|
||||||
|
|
||||||
|
strncpy(buf, tmp, n - 1);
|
||||||
|
buf[n - 1] = '\0';
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int git_current_branch(const char *repo_path, char *out, size_t n)
|
||||||
|
{
|
||||||
|
char cmd[IDX_PATH_LEN + 64];
|
||||||
|
snprintf(cmd, sizeof(cmd),
|
||||||
|
"git -C \"%s\" branch --show-current 2>/dev/null", repo_path);
|
||||||
|
|
||||||
|
if (git_capture(cmd, out, n) != 0 || out[0] == '\0') {
|
||||||
|
/* detached HEAD — show the short hash instead */
|
||||||
|
snprintf(cmd, sizeof(cmd),
|
||||||
|
"git -C \"%s\" rev-parse --short HEAD 2>/dev/null", repo_path);
|
||||||
|
if (git_capture(cmd, out, n) != 0)
|
||||||
|
strncpy(out, "(unknown)", n - 1);
|
||||||
|
else {
|
||||||
|
/* prepend marker so caller knows it's detached */
|
||||||
|
char tmp[IDX_PATH_LEN];
|
||||||
|
snprintf(tmp, sizeof(tmp), "(detached:%s)", out);
|
||||||
|
strncpy(out, tmp, n - 1);
|
||||||
|
}
|
||||||
|
out[n - 1] = '\0';
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int git_is_dirty(const char *repo_path)
|
||||||
|
{
|
||||||
|
char cmd[IDX_PATH_LEN + 64];
|
||||||
|
snprintf(cmd, sizeof(cmd),
|
||||||
|
"git -C \"%s\" status --porcelain 2>/dev/null", repo_path);
|
||||||
|
|
||||||
|
FILE *p = popen(cmd, "r");
|
||||||
|
if (!p) return -1;
|
||||||
|
|
||||||
|
/* Any output at all means dirty */
|
||||||
|
char buf[4];
|
||||||
|
int dirty = (fgets(buf, sizeof(buf), p) != NULL);
|
||||||
|
pclose(p);
|
||||||
|
return dirty;
|
||||||
|
}
|
||||||
|
|
||||||
|
int git_behind_count(const char *repo_path)
|
||||||
|
{
|
||||||
|
/* Fetch quietly first; ignore failure (offline is not an error) */
|
||||||
|
char cmd[IDX_PATH_LEN + 128];
|
||||||
|
snprintf(cmd, sizeof(cmd),
|
||||||
|
"git -C \"%s\" fetch --quiet 2>/dev/null", repo_path);
|
||||||
|
(void)system(cmd);
|
||||||
|
|
||||||
|
/* Count commits reachable from upstream but not from HEAD */
|
||||||
|
snprintf(cmd, sizeof(cmd),
|
||||||
|
"git -C \"%s\" rev-list HEAD..@{u} --count 2>/dev/null",
|
||||||
|
repo_path);
|
||||||
|
|
||||||
|
char buf[32] = {0};
|
||||||
|
if (git_capture(cmd, buf, sizeof(buf)) != 0)
|
||||||
|
return -1; /* no upstream configured, or other error */
|
||||||
|
|
||||||
|
return atoi(buf);
|
||||||
|
}
|
||||||
|
|||||||
+82
@@ -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>
|
#include <errno.h>
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
* SECTION 1 — TARGET PICKER
|
* SECTION 1 - TARGET PICKER
|
||||||
* ================================================================ */
|
* ================================================================ */
|
||||||
|
|
||||||
#define PICKER_MIN_W 40
|
#define PICKER_MIN_W 40
|
||||||
@@ -29,18 +29,15 @@ int tui_pick_target(const MakeTargets *targets, char *selected, size_t sel_size)
|
|||||||
if (has_colors()) {
|
if (has_colors()) {
|
||||||
start_color();
|
start_color();
|
||||||
use_default_colors();
|
use_default_colors();
|
||||||
init_pair(1, COLOR_BLACK, COLOR_CYAN); /* selected row */
|
init_pair(1, COLOR_BLACK, COLOR_WHITE); /* selected row */
|
||||||
init_pair(2, COLOR_CYAN, -1); /* border colour */
|
|
||||||
init_pair(3, COLOR_WHITE, -1); /* normal row */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
int rows, cols;
|
int rows, cols;
|
||||||
getmaxyx(stdscr, rows, cols);
|
getmaxyx(stdscr, rows, cols);
|
||||||
|
|
||||||
int list_h = targets->count + 2; /* +2 for border */
|
int list_h = targets->count + 2;
|
||||||
int list_w = PICKER_MIN_W;
|
int list_w = PICKER_MIN_W;
|
||||||
|
|
||||||
/* find longest target name and widen accordingly */
|
|
||||||
for (int i = 0; i < targets->count; i++) {
|
for (int i = 0; i < targets->count; i++) {
|
||||||
int l = (int)strlen(targets->names[i]) + 4;
|
int l = (int)strlen(targets->names[i]) + 4;
|
||||||
if (l > list_w) list_w = l;
|
if (l > list_w) list_w = l;
|
||||||
@@ -51,17 +48,16 @@ int tui_pick_target(const MakeTargets *targets, char *selected, size_t sel_size)
|
|||||||
int win_y = (rows - list_h) / 2;
|
int win_y = (rows - list_h) / 2;
|
||||||
int win_x = (cols - list_w) / 2;
|
int win_x = (cols - list_w) / 2;
|
||||||
|
|
||||||
/* instruction line above the box */
|
|
||||||
int cur = 0;
|
int cur = 0;
|
||||||
int scroll_offset = 0;
|
int scroll_offset = 0;
|
||||||
int visible = list_h - 2; /* rows inside border */
|
int visible = list_h - 2;
|
||||||
|
|
||||||
WINDOW *win = newwin(list_h, list_w, win_y, win_x);
|
WINDOW *win = newwin(list_h, list_w, win_y, win_x);
|
||||||
|
keypad(win, TRUE);
|
||||||
|
|
||||||
/* header hint */
|
|
||||||
attron(A_DIM);
|
attron(A_DIM);
|
||||||
mvprintw(win_y - 2, win_x,
|
mvprintw(win_y - 2, win_x,
|
||||||
"Select make target K - UP J - DOWN to navigate Enter - select \u00b7 q - cancel");
|
"Select make target J/K navigate | Enter select | q cancel");
|
||||||
attroff(A_DIM);
|
attroff(A_DIM);
|
||||||
refresh();
|
refresh();
|
||||||
|
|
||||||
@@ -70,9 +66,7 @@ int tui_pick_target(const MakeTargets *targets, char *selected, size_t sel_size)
|
|||||||
|
|
||||||
while (!done) {
|
while (!done) {
|
||||||
werase(win);
|
werase(win);
|
||||||
if (has_colors()) wattron(win, COLOR_PAIR(2));
|
|
||||||
box(win, 0, 0);
|
box(win, 0, 0);
|
||||||
if (has_colors()) wattroff(win, COLOR_PAIR(2));
|
|
||||||
|
|
||||||
for (int i = 0; i < visible && (i + scroll_offset) < targets->count; i++) {
|
for (int i = 0; i < visible && (i + scroll_offset) < targets->count; i++) {
|
||||||
int idx = i + scroll_offset;
|
int idx = i + scroll_offset;
|
||||||
@@ -84,10 +78,8 @@ int tui_pick_target(const MakeTargets *targets, char *selected, size_t sel_size)
|
|||||||
if (has_colors()) wattroff(win, COLOR_PAIR(1) | A_BOLD);
|
if (has_colors()) wattroff(win, COLOR_PAIR(1) | A_BOLD);
|
||||||
else wattroff(win, A_REVERSE);
|
else wattroff(win, A_REVERSE);
|
||||||
} else {
|
} else {
|
||||||
if (has_colors()) wattron(win, COLOR_PAIR(3));
|
|
||||||
mvwprintw(win, i + 1, 1, " %-*s",
|
mvwprintw(win, i + 1, 1, " %-*s",
|
||||||
list_w - 4, targets->names[idx]);
|
list_w - 4, targets->names[idx]);
|
||||||
if (has_colors()) wattroff(win, COLOR_PAIR(3));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +111,7 @@ int tui_pick_target(const MakeTargets *targets, char *selected, size_t sel_size)
|
|||||||
done = 1;
|
done = 1;
|
||||||
break;
|
break;
|
||||||
case 'q':
|
case 'q':
|
||||||
case 27: /* ESC */
|
case 27:
|
||||||
done = 1;
|
done = 1;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -131,20 +123,19 @@ int tui_pick_target(const MakeTargets *targets, char *selected, size_t sel_size)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
* SECTION 2 — LOG BROWSER
|
* SECTION 2 - LOG BROWSER
|
||||||
* ================================================================ */
|
* ================================================================ */
|
||||||
|
|
||||||
#define MAX_LOGS 512
|
#define MAX_LOGS 512
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
char name[256]; /* filename only */
|
char name[256];
|
||||||
char path[768]; /* full path */
|
char path[768];
|
||||||
time_t mtime;
|
time_t mtime;
|
||||||
} LogEntry;
|
} LogEntry;
|
||||||
|
|
||||||
static int log_entry_cmp(const void *a, const void *b)
|
static int log_entry_cmp(const void *a, const void *b)
|
||||||
{
|
{
|
||||||
/* newest first */
|
|
||||||
const LogEntry *la = (const LogEntry *)a;
|
const LogEntry *la = (const LogEntry *)a;
|
||||||
const LogEntry *lb = (const LogEntry *)b;
|
const LogEntry *lb = (const LogEntry *)b;
|
||||||
if (lb->mtime > la->mtime) return 1;
|
if (lb->mtime > la->mtime) return 1;
|
||||||
@@ -162,7 +153,6 @@ static int load_log_entries(const char *log_dir,
|
|||||||
struct dirent *de;
|
struct dirent *de;
|
||||||
while ((de = readdir(d)) && count < max) {
|
while ((de = readdir(d)) && count < max) {
|
||||||
if (de->d_name[0] == '.') continue;
|
if (de->d_name[0] == '.') continue;
|
||||||
/* only .log files */
|
|
||||||
const char *dot = strrchr(de->d_name, '.');
|
const char *dot = strrchr(de->d_name, '.');
|
||||||
if (!dot || strcmp(dot, ".log")) continue;
|
if (!dot || strcmp(dot, ".log")) continue;
|
||||||
|
|
||||||
@@ -184,11 +174,6 @@ static int load_log_entries(const char *log_dir,
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
* Read up to `max_lines` trailing lines from `path` into `lines`.
|
|
||||||
* Returns the number of lines read.
|
|
||||||
* Caller owns the memory and should free each pointer.
|
|
||||||
*/
|
|
||||||
#define PREVIEW_MAX 128
|
#define PREVIEW_MAX 128
|
||||||
|
|
||||||
static int read_tail(const char *path, char **lines, int max_lines)
|
static int read_tail(const char *path, char **lines, int max_lines)
|
||||||
@@ -196,17 +181,14 @@ static int read_tail(const char *path, char **lines, int max_lines)
|
|||||||
FILE *f = fopen(path, "r");
|
FILE *f = fopen(path, "r");
|
||||||
if (!f) return 0;
|
if (!f) return 0;
|
||||||
|
|
||||||
/* Circular buffer approach */
|
|
||||||
char **buf = calloc(max_lines, sizeof(char *));
|
char **buf = calloc(max_lines, sizeof(char *));
|
||||||
if (!buf) { fclose(f); return 0; }
|
if (!buf) { fclose(f); return 0; }
|
||||||
|
|
||||||
char tmp[1024];
|
char tmp[1024];
|
||||||
int idx = 0, total = 0;
|
int idx = 0, total = 0;
|
||||||
while (fgets(tmp, sizeof(tmp), f)) {
|
while (fgets(tmp, sizeof(tmp), f)) {
|
||||||
/* strip newline */
|
|
||||||
size_t l = strlen(tmp);
|
size_t l = strlen(tmp);
|
||||||
if (l > 0 && tmp[l-1] == '\n') tmp[l-1] = '\0';
|
if (l > 0 && tmp[l-1] == '\n') tmp[l-1] = '\0';
|
||||||
|
|
||||||
free(buf[idx % max_lines]);
|
free(buf[idx % max_lines]);
|
||||||
buf[idx % max_lines] = strdup(tmp);
|
buf[idx % max_lines] = strdup(tmp);
|
||||||
idx++;
|
idx++;
|
||||||
@@ -220,7 +202,6 @@ static int read_tail(const char *path, char **lines, int max_lines)
|
|||||||
for (int i = 0; i < have; i++)
|
for (int i = 0; i < have; i++)
|
||||||
lines[i] = buf[(start + i) % max_lines];
|
lines[i] = buf[(start + i) % max_lines];
|
||||||
|
|
||||||
/* free slots that weren't returned */
|
|
||||||
for (int i = have; i < max_lines; i++) {
|
for (int i = have; i < max_lines; i++) {
|
||||||
free(buf[(start + i) % max_lines]);
|
free(buf[(start + i) % max_lines]);
|
||||||
buf[(start + i) % max_lines] = NULL;
|
buf[(start + i) % max_lines] = NULL;
|
||||||
@@ -247,24 +228,20 @@ void tui_log_browser(const char *log_dir)
|
|||||||
if (has_colors()) {
|
if (has_colors()) {
|
||||||
start_color();
|
start_color();
|
||||||
use_default_colors();
|
use_default_colors();
|
||||||
init_pair(1, COLOR_BLACK, COLOR_CYAN); /* selected row */
|
init_pair(1, COLOR_BLACK, COLOR_WHITE); /* selected row */
|
||||||
init_pair(2, COLOR_CYAN, -1); /* border */
|
|
||||||
init_pair(3, COLOR_WHITE, -1); /* normal row */
|
|
||||||
init_pair(4, COLOR_YELLOW,-1); /* preview text */
|
|
||||||
init_pair(5, COLOR_RED, -1); /* status/warn */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
int rows, cols;
|
int rows, cols;
|
||||||
getmaxyx(stdscr, rows, cols);
|
getmaxyx(stdscr, rows, cols);
|
||||||
|
|
||||||
/* layout: left pane = 40% width, right pane = rest */
|
|
||||||
int left_w = cols * 40 / 100;
|
int left_w = cols * 40 / 100;
|
||||||
if (left_w < 30) left_w = 30;
|
if (left_w < 30) left_w = 30;
|
||||||
int right_w = cols - left_w - 1; /* -1 for divider */
|
int right_w = cols - left_w - 1;
|
||||||
int pane_h = rows - 3; /* -3 for title + hint bar */
|
int pane_h = rows - 3;
|
||||||
|
|
||||||
WINDOW *wleft = newwin(pane_h, left_w, 1, 0);
|
WINDOW *wleft = newwin(pane_h, left_w, 1, 0);
|
||||||
WINDOW *wright = newwin(pane_h, right_w, 1, left_w + 1);
|
WINDOW *wright = newwin(pane_h, right_w, 1, left_w + 1);
|
||||||
|
keypad(wleft, TRUE);
|
||||||
|
|
||||||
int cur = 0, scroll = 0;
|
int cur = 0, scroll = 0;
|
||||||
int done = 0;
|
int done = 0;
|
||||||
@@ -280,11 +257,11 @@ void tui_log_browser(const char *log_dir)
|
|||||||
clrtoeol();
|
clrtoeol();
|
||||||
|
|
||||||
/* ---- hint bar ---- */
|
/* ---- hint bar ---- */
|
||||||
if (has_colors()) attron(COLOR_PAIR(2));
|
attron(A_REVERSE);
|
||||||
mvprintw(rows - 2, 0,
|
mvprintw(rows - 2, 0,
|
||||||
" \u2191\u2193 navigate Enter open in less d delete q quit "
|
" J/K navigate Enter open in less d delete q quit "
|
||||||
" ");
|
" ");
|
||||||
if (has_colors()) attroff(COLOR_PAIR(2));
|
attroff(A_REVERSE);
|
||||||
|
|
||||||
/* ---- vertical divider ---- */
|
/* ---- vertical divider ---- */
|
||||||
for (int y = 1; y < rows - 2; y++)
|
for (int y = 1; y < rows - 2; y++)
|
||||||
@@ -292,9 +269,7 @@ void tui_log_browser(const char *log_dir)
|
|||||||
|
|
||||||
/* ---- left pane: file list ---- */
|
/* ---- left pane: file list ---- */
|
||||||
werase(wleft);
|
werase(wleft);
|
||||||
if (has_colors()) wattron(wleft, COLOR_PAIR(2));
|
|
||||||
box(wleft, 0, 0);
|
box(wleft, 0, 0);
|
||||||
if (has_colors()) wattroff(wleft, COLOR_PAIR(2));
|
|
||||||
|
|
||||||
int visible = pane_h - 2;
|
int visible = pane_h - 2;
|
||||||
if (visible < 1) visible = 1;
|
if (visible < 1) visible = 1;
|
||||||
@@ -304,7 +279,6 @@ void tui_log_browser(const char *log_dir)
|
|||||||
} else {
|
} else {
|
||||||
for (int i = 0; i < visible && (i + scroll) < count; i++) {
|
for (int i = 0; i < visible && (i + scroll) < count; i++) {
|
||||||
int idx = i + scroll;
|
int idx = i + scroll;
|
||||||
/* trim the .log extension for display */
|
|
||||||
char disp[256];
|
char disp[256];
|
||||||
strncpy(disp, entries[idx].name, sizeof(disp) - 1);
|
strncpy(disp, entries[idx].name, sizeof(disp) - 1);
|
||||||
char *dot = strrchr(disp, '.');
|
char *dot = strrchr(disp, '.');
|
||||||
@@ -323,20 +297,17 @@ void tui_log_browser(const char *log_dir)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
wrefresh(wleft);
|
/* NOTE: no wrefresh(wleft) here - flush everything together below */
|
||||||
|
|
||||||
/* ---- right pane: preview ---- */
|
/* ---- right pane: preview ---- */
|
||||||
werase(wright);
|
werase(wright);
|
||||||
if (has_colors()) wattron(wright, COLOR_PAIR(2));
|
|
||||||
box(wright, 0, 0);
|
box(wright, 0, 0);
|
||||||
if (has_colors()) wattroff(wright, COLOR_PAIR(2));
|
|
||||||
|
|
||||||
if (count > 0 && cur < count) {
|
if (count > 0 && cur < count) {
|
||||||
int preview_lines = pane_h - 2;
|
int preview_lines = pane_h - 2;
|
||||||
char **lines = calloc(preview_lines, sizeof(char *));
|
char **lines = calloc(preview_lines, sizeof(char *));
|
||||||
if (lines) {
|
if (lines) {
|
||||||
int n = read_tail(entries[cur].path, lines, preview_lines);
|
int n = read_tail(entries[cur].path, lines, preview_lines);
|
||||||
if (has_colors()) wattron(wright, COLOR_PAIR(4));
|
|
||||||
for (int i = 0; i < n; i++) {
|
for (int i = 0; i < n; i++) {
|
||||||
if (lines[i]) {
|
if (lines[i]) {
|
||||||
mvwprintw(wright, i + 1, 1, "%-*.*s",
|
mvwprintw(wright, i + 1, 1, "%-*.*s",
|
||||||
@@ -344,17 +315,20 @@ void tui_log_browser(const char *log_dir)
|
|||||||
free(lines[i]);
|
free(lines[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (has_colors()) wattroff(wright, COLOR_PAIR(4));
|
|
||||||
free(lines);
|
free(lines);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
mvwprintw(wright, 2, 2, "(no log selected)");
|
mvwprintw(wright, 2, 2, "(no log selected)");
|
||||||
}
|
}
|
||||||
wrefresh(wright);
|
|
||||||
refresh();
|
/* Atomic flush - stdscr first, then both panes on top */
|
||||||
|
wnoutrefresh(stdscr);
|
||||||
|
wnoutrefresh(wleft);
|
||||||
|
wnoutrefresh(wright);
|
||||||
|
doupdate();
|
||||||
|
|
||||||
/* ---- input ---- */
|
/* ---- input ---- */
|
||||||
int ch = getch();
|
int ch = wgetch(wleft);
|
||||||
switch (ch) {
|
switch (ch) {
|
||||||
case KEY_UP:
|
case KEY_UP:
|
||||||
case 'k':
|
case 'k':
|
||||||
@@ -380,7 +354,6 @@ void tui_log_browser(const char *log_dir)
|
|||||||
snprintf(cmd, sizeof(cmd),
|
snprintf(cmd, sizeof(cmd),
|
||||||
"less \"%s\"", entries[cur].path);
|
"less \"%s\"", entries[cur].path);
|
||||||
system(cmd);
|
system(cmd);
|
||||||
/* re-init after less exits */
|
|
||||||
initscr();
|
initscr();
|
||||||
noecho();
|
noecho();
|
||||||
cbreak();
|
cbreak();
|
||||||
@@ -389,25 +362,21 @@ void tui_log_browser(const char *log_dir)
|
|||||||
if (has_colors()) {
|
if (has_colors()) {
|
||||||
start_color();
|
start_color();
|
||||||
use_default_colors();
|
use_default_colors();
|
||||||
init_pair(1, COLOR_BLACK, COLOR_CYAN);
|
init_pair(1, COLOR_BLACK, COLOR_WHITE);
|
||||||
init_pair(2, COLOR_CYAN, -1);
|
|
||||||
init_pair(3, COLOR_WHITE, -1);
|
|
||||||
init_pair(4, COLOR_YELLOW,-1);
|
|
||||||
init_pair(5, COLOR_RED, -1);
|
|
||||||
}
|
}
|
||||||
|
touchwin(wleft);
|
||||||
|
touchwin(wright);
|
||||||
clear();
|
clear();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'd':
|
case 'd':
|
||||||
if (count > 0 && cur < count) {
|
if (count > 0 && cur < count) {
|
||||||
/* confirm */
|
|
||||||
mvprintw(rows - 1, 0,
|
mvprintw(rows - 1, 0,
|
||||||
"Delete %s? [y/N] ", entries[cur].name);
|
"Delete %s? [y/N] ", entries[cur].name);
|
||||||
refresh();
|
refresh();
|
||||||
int confirm = getch();
|
int confirm = getch();
|
||||||
if (confirm == 'y' || confirm == 'Y') {
|
if (confirm == 'y' || confirm == 'Y') {
|
||||||
remove(entries[cur].path);
|
remove(entries[cur].path);
|
||||||
/* reload list */
|
|
||||||
count = load_log_entries(log_dir, entries, MAX_LOGS);
|
count = load_log_entries(log_dir, entries, MAX_LOGS);
|
||||||
if (cur >= count) cur = count > 0 ? count - 1 : 0;
|
if (cur >= count) cur = count > 0 ? count - 1 : 0;
|
||||||
if (scroll > cur) scroll = cur;
|
if (scroll > cur) scroll = cur;
|
||||||
@@ -416,7 +385,7 @@ void tui_log_browser(const char *log_dir)
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'q':
|
case 'q':
|
||||||
case 27: /* ESC */
|
case 27:
|
||||||
done = 1;
|
done = 1;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -427,3 +396,202 @@ void tui_log_browser(const char *log_dir)
|
|||||||
endwin();
|
endwin();
|
||||||
free(entries);
|
free(entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
* SECTION 3 - PROJECT OVERVIEW
|
||||||
|
* ================================================================ */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Column layout:
|
||||||
|
* ST PROJECT NAME LAST BUILD RESULT HEAD HASH
|
||||||
|
*
|
||||||
|
* Status symbols:
|
||||||
|
* * last build passed
|
||||||
|
* X last build failed
|
||||||
|
* - never built
|
||||||
|
*
|
||||||
|
* Keys:
|
||||||
|
* J/K navigate Enter build l logs q/ESC quit
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "index.h"
|
||||||
|
#include "git_ops.h"
|
||||||
|
|
||||||
|
#define COL_STATUS_W 3
|
||||||
|
#define COL_NAME_W 24
|
||||||
|
#define COL_TIME_W 18
|
||||||
|
#define COL_RC_W 8
|
||||||
|
#define COL_HASH_W 10
|
||||||
|
|
||||||
|
static void fmt_ts(time_t ts, char *out, size_t n)
|
||||||
|
{
|
||||||
|
if (ts == 0) {
|
||||||
|
strncpy(out, "(never)", n - 1);
|
||||||
|
out[n-1] = '\0';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
struct tm *t = localtime(&ts);
|
||||||
|
strftime(out, n, "%d %b %H:%M", t);
|
||||||
|
}
|
||||||
|
|
||||||
|
int tui_project_overview(ProjectIndex *idx,
|
||||||
|
const char *clone_dir,
|
||||||
|
const char *log_dir,
|
||||||
|
char *selected_project,
|
||||||
|
size_t sel_size)
|
||||||
|
{
|
||||||
|
(void)clone_dir;
|
||||||
|
|
||||||
|
if (!idx || idx->count == 0) return -1;
|
||||||
|
|
||||||
|
initscr();
|
||||||
|
noecho();
|
||||||
|
cbreak();
|
||||||
|
keypad(stdscr, TRUE);
|
||||||
|
curs_set(0);
|
||||||
|
|
||||||
|
if (has_colors()) {
|
||||||
|
start_color();
|
||||||
|
use_default_colors();
|
||||||
|
init_pair(1, COLOR_BLACK, COLOR_WHITE); /* selected row */
|
||||||
|
}
|
||||||
|
|
||||||
|
int rows, cols;
|
||||||
|
getmaxyx(stdscr, rows, cols);
|
||||||
|
|
||||||
|
int cur = 0, scroll = 0;
|
||||||
|
int done = 0;
|
||||||
|
int result = -1;
|
||||||
|
|
||||||
|
while (!done) {
|
||||||
|
getmaxyx(stdscr, rows, cols);
|
||||||
|
int visible = rows - 5;
|
||||||
|
if (visible < 1) visible = 1;
|
||||||
|
|
||||||
|
erase();
|
||||||
|
|
||||||
|
/* ---- title bar ---- */
|
||||||
|
attron(A_BOLD);
|
||||||
|
mvprintw(0, 2, "gbuild 1.0.0 - project overview");
|
||||||
|
attroff(A_BOLD);
|
||||||
|
|
||||||
|
/* ---- column header ---- */
|
||||||
|
attron(A_BOLD);
|
||||||
|
mvprintw(2, 2, " %-*s %-*s %-*s %-*s",
|
||||||
|
COL_NAME_W, "PROJECT",
|
||||||
|
COL_TIME_W, "LAST BUILD",
|
||||||
|
COL_RC_W, "RESULT",
|
||||||
|
COL_HASH_W, "HEAD");
|
||||||
|
attroff(A_BOLD);
|
||||||
|
|
||||||
|
/* separator */
|
||||||
|
mvhline(3, 0, ACS_HLINE, cols);
|
||||||
|
|
||||||
|
/* ---- project rows ---- */
|
||||||
|
for (int i = 0; i < visible && (i + scroll) < idx->count; i++) {
|
||||||
|
int idx_i = i + scroll;
|
||||||
|
ProjectRecord *r = &idx->projects[idx_i];
|
||||||
|
|
||||||
|
char ts_str[32];
|
||||||
|
fmt_ts(r->last_build_ts, ts_str, sizeof(ts_str));
|
||||||
|
|
||||||
|
char shorthash[12] = "--------";
|
||||||
|
if (r->last_head_hash[0])
|
||||||
|
strncpy(shorthash, r->last_head_hash, 8);
|
||||||
|
shorthash[8] = '\0';
|
||||||
|
|
||||||
|
char rc_str[16];
|
||||||
|
if (r->last_build_rc == -1) strncpy(rc_str, "-", sizeof(rc_str) - 1);
|
||||||
|
else if (r->last_build_rc == 0) strncpy(rc_str, "PASS", sizeof(rc_str) - 1);
|
||||||
|
else snprintf(rc_str, sizeof(rc_str), "FAIL(%d)", r->last_build_rc);
|
||||||
|
|
||||||
|
const char *sym = (r->last_build_rc == 0) ? "*" :
|
||||||
|
(r->last_build_rc > 0) ? "X" : "-";
|
||||||
|
|
||||||
|
int y = 4 + i;
|
||||||
|
|
||||||
|
if (idx_i == cur) {
|
||||||
|
/* fill the whole row first */
|
||||||
|
if (has_colors()) attron(COLOR_PAIR(1) | A_BOLD);
|
||||||
|
else attron(A_REVERSE);
|
||||||
|
mvprintw(y, 0, "%*s", cols, "");
|
||||||
|
mvprintw(y, 2, "%s %-*s %-*s %-*s %-*s",
|
||||||
|
sym,
|
||||||
|
COL_NAME_W, r->name,
|
||||||
|
COL_TIME_W, ts_str,
|
||||||
|
COL_RC_W, rc_str,
|
||||||
|
COL_HASH_W, shorthash);
|
||||||
|
if (has_colors()) attroff(COLOR_PAIR(1) | A_BOLD);
|
||||||
|
else attroff(A_REVERSE);
|
||||||
|
} else {
|
||||||
|
mvprintw(y, 2, "%s %-*s %-*s %-*s %-*s",
|
||||||
|
sym,
|
||||||
|
COL_NAME_W, r->name,
|
||||||
|
COL_TIME_W, ts_str,
|
||||||
|
COL_RC_W, rc_str,
|
||||||
|
COL_HASH_W, shorthash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- hint bar ---- */
|
||||||
|
attron(A_REVERSE);
|
||||||
|
mvprintw(rows - 1, 0,
|
||||||
|
" J/K navigate Enter build l logs q quit"
|
||||||
|
" ");
|
||||||
|
attroff(A_REVERSE);
|
||||||
|
|
||||||
|
wnoutrefresh(stdscr);
|
||||||
|
doupdate();
|
||||||
|
|
||||||
|
/* ---- input ---- */
|
||||||
|
int ch = getch();
|
||||||
|
switch (ch) {
|
||||||
|
case KEY_UP:
|
||||||
|
case 'k':
|
||||||
|
if (cur > 0) {
|
||||||
|
cur--;
|
||||||
|
if (cur < scroll) scroll = cur;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case KEY_DOWN:
|
||||||
|
case 'j':
|
||||||
|
if (cur < idx->count - 1) {
|
||||||
|
cur++;
|
||||||
|
if (cur >= scroll + visible)
|
||||||
|
scroll = cur - visible + 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case '\n':
|
||||||
|
case '\r':
|
||||||
|
case KEY_ENTER:
|
||||||
|
strncpy(selected_project,
|
||||||
|
idx->projects[cur].name, sel_size - 1);
|
||||||
|
selected_project[sel_size - 1] = '\0';
|
||||||
|
result = cur;
|
||||||
|
done = 1;
|
||||||
|
break;
|
||||||
|
case 'l':
|
||||||
|
endwin();
|
||||||
|
tui_log_browser(log_dir);
|
||||||
|
initscr();
|
||||||
|
noecho();
|
||||||
|
cbreak();
|
||||||
|
keypad(stdscr, TRUE);
|
||||||
|
curs_set(0);
|
||||||
|
if (has_colors()) {
|
||||||
|
start_color();
|
||||||
|
use_default_colors();
|
||||||
|
init_pair(1, COLOR_BLACK, COLOR_WHITE);
|
||||||
|
}
|
||||||
|
clear();
|
||||||
|
break;
|
||||||
|
case 'q':
|
||||||
|
case 27:
|
||||||
|
done = 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
endwin();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user