gift

The thing you watch for happens, and the script you named runs.

gift watches for the thing you care about — a GitHub push, the clipboard changing, a page that came back different, a file that was written — and answers it by running a script in a folder: build the site, pull the notes, restart the thing that just changed. One process watches all four, and around it sits a handful of git chores, run from the same command.

macOS · Linux·Node 18+·MIT·v0.2.0

A push, and what it runs

The oldest of the triggers is a GitHub push. That hook is a repository, the branches worth answering, a script, and the folder that script runs in. A delivery arrives and every hook watching that repository and that branch runs. Pick a push and watch it land:

Push
POST /hooks/githubsignature ok
push·gcc3/gcc3·refs/heads/main·3 commits
hooks
sitegcc3/gcc3main, master./build.sh~/sites/gcc3match
notesgcc3/gcc3-content*./pull.sh~/sites/gcc3/public/notes—
giftlhypds/giftmaster./restart.sh~/gift—
./build.sh ran in ~/sites/gcc3 · exit 0

The secret is checked before any of that: a delivery whose signature does not add up is refused. gift create can make the webhook on GitHub for you with gh, and then asks GitHub back whether it is really there — a hook in your file that GitHub never calls is worth being told about rather than assumed to work.

Four things a hook can be built on

A hook is two halves: a trigger that says what has to happen, and a script to run when it does. gift triggers lists the four, gift create asks which one this hook is, and every question after that follows from the answer.

github
A push to a repository you named, delivered to a port of your own and checked against the webhook secret before anything runs.
clipboard
The clipboard changing — or changing to something that contains, equals or matches what you said. The content reaches the script in GIFT_CLIPBOARD, and what a pattern captured in GIFT_MATCH_1 and up.
website
A page polled on an interval, firing when it changes, when it matches, or every time. A page behind a login is still a page worth watching: the hook carries the cookies, storage values and headers a browser would send, and a refresh block tells gift which answer means expired and where the new token is — it renews, polls again, and writes the rotated value back itself.
file
A path or a pattern — *.yml, **/*.js — watched for files added, changed or deleted. A burst of writes settles into one run, and the list of what moved is handed to the script in GIFT_FILES_FILE.

What was already there when the server started counts as nothing: restarting re-fires no hook. Each hook keeps its own books under logs/hooks/<name> — a line per request in hook.log, fired, quiet or failed, and an error.log that only exists once something has gone wrong, which is what makes one showing up worth noticing. A trigger is also just a folder: drop one into triggers/ with an index.js and it earns its place in gift create, config.json and gift list.

What gift create asks

Trigger
Which of the four this hook is built on. The questions below are the GitHub trigger's; each of the others asks for its own.
Repository
The one whose pushes this hook answers, as owner/name.
Branches
Comma separated, or * for any; main, master unless you say otherwise. A push to a branch outside the list is answered with No match and runs nothing.
Name
A label, so gift list reads as something. Names may be reused — same-named hooks are deleted by their position in the list.
Script
What to run when a delivery matches. A script that is not executable is noticed, and gift create offers to fix its permissions before saving.
Working directory
Where to run it. The server restarts itself once the hook is written, so there is nothing else to do.

The server

gift serve
Pulls the latest code, rebuilds, and starts listening.
gift restart
Puts the server back on the code already on disk — no pull, no rebuild, so it needs no network and returns straight away.
gift stop · gift status
Stops it; says whether it is answering, what it is watching, and names any error log with something in it. --json for a script.
gift list · gift create · gift delete
Show the hooks, add one, remove one. The server restarts itself after a change, and delete takes the hook's folder of logs with it.
gift triggers
The four things a hook can be built on. gift help <trigger> gives its hooks.json fields and the variables its script is handed.
gift log
The last ten lines, and then each line as the server writes it. gift log 20 for more, --no-follow to print them and exit.
gift config
Opens config.json in $EDITOR; --path prints where it is.
gift update
A git pull --ff-only in the folder gift is installed from — so it is the checkout's way of upgrading, and has nothing to pull in a release install.

The port also carries a page of its own: the hooks as they are configured, and the last day of whatever happened — a delivery, a clipboard change, a page that came back different, a file that was written — newest first, with what each one's script printed, filling in while it is still printing it. Anything that looks like a credential is redacted before it is shown.

The other things it does

The same command runs a small set of git chores. gift help lists them, gift run opens a picker — a cursor to move, enter to run, a number key to run that row straight away — and gift <name> skips the picker. Enough of a name will do, and anything after it is passed on to the function itself.

repo-master

Every git repository under one folder, in a table that keeps itself up to date: which branch each is on, whether anything is uncommitted, how many lines that is, and when it last moved. Pick some rows and the menu is what may be done to them — open one in an editor or an agent, read a diff, fetch, pull, push, branch, merge, rebase, stash, or commit and push the lot. A stash can be put back, a change discarded, a worktree branched off, a folder deleted outright, and / finds a repository in a folder too full to read. Nested checkouts and submodules get a row of their own.

runs asgift repo

weekly-prs

A week of pull requests, grouped by the day they were opened. Weeks run Monday to Sunday, and it asks how many weeks back unless you have already said.

runs asgift weekly

pull-repos

git pull --recurse-submodules --autostash in every repository below one folder, however deep it is. --dry-run says what it would pull without pulling it.

runs asgift pull

clone-repos

Clone every repository owned by a GitHub organization or user into one folder. Public and private repositories, forks and archives are included unless you leave them out; clones run in parallel, existing folders can be pulled or skipped, and --dry-run shows the whole job first.

runs asgift clone

fetch-repo-files

Bring down a whole repository, one folder, or one file from a GitHub URL without cloning or leaving a .git directory. It understands repository, tree, blob and raw links, and can read a chosen branch, tag or commit — including from a private repository when a token is available.

runs asgift fetch

One file of settings

Every setting is in one file — config.json, beside the code. gift config opens it in your editor. Three kinds of section, and which one a setting belongs in follows from who reads it: gift's own are at the top level, each trigger's under triggers.<type>, each function's under functions.<name>:

{
    "pm2_name": "gift",
    "port": 3999,
    "triggers": {
        "github": { "github_webhook_secret": "…", "webhook_url": "…" },
        "website": { "interval": 60000 }
    },
    "functions": {
        "repo-master": { "repo_root": "/Users/me/projects" },
        "weekly-prs": { "repos": "owner/repo1,owner/repo2", "author": "octocat" }
    }
}

It is written on first use with the settings worth looking at already in it, at their defaults, so opening it shows what there is to set rather than a blank page. Each one is declared in a config.schema.json next to the code that reads it — where its default, its description and the environment variable it reaches a script as all come from. A value already in the environment wins over the file, and a flag wins over both. The file is git-ignored and written 0600, because the webhook secret is in it — and hooks.json is treated the same way, because a website hook may be holding a login.

Getting it

One line. It takes the latest release, unpacks it into ~/.gift, and runs the setup and install steps from there — so it asks for the public delivery URL as it goes, and leaves the gift command on your PATH. Node 18 or newer, curl and unzip; gift serve keeps the server under PM2, so npm i -g pm2 if it is not there yet.

curl -fsSL https://raw.githubusercontent.com/lhypds/gift/master/get.sh | bash

GIFT_INSTALL_DIR unpacks it somewhere other than ~/.gift, and GIFT_INSTALL_VERSION=v0.0.1 pins a release rather than taking the newest.

Upgrading is the same line again: config.json, hooks.json and the logs are carried across, so the webhook secret and the hooks you configured survive the swap. A download that fails leaves the install that was working exactly where it was.

  1. Install it — the line above. It asks for the public URL that GitHub will deliver to, which is the one thing it cannot work out for itself.
  2. gift config — the port to listen on, and the webhook secret to check deliveries against.
  3. gift create — first which trigger, then what that trigger needs. For GitHub: the repository, the branches, the script and where to run it. Answer yes and gh makes the webhook on GitHub for you; answer no and you add it yourself under Settings ▸ Webhooks, with the same secret.
  4. gift serve — and the next push runs it. gift log to watch it happen.

~/.gift/uninstall.sh takes the gift command back off again, and deleting ~/.gift removes the rest; from a checkout it is ./uninstall.sh.