Distributed Git or keeping your remote in sync
In my current setup I have laptop I'm working on, and small local server to play with.
Let's pretend we want to have some web site with bunch of files hosted on server.
We may:
- manually ssh into server and edit files there
- keep files locally and rsync them to remote
- use git 🤔
Goal:
- as usual store repo in github
- have local clone to edit files and on server the same clone to host site
- once if push changes - they should automatically arrive to server
- ideally server may want to run some scripts once changes arrived
Before proceeding there are two topics worth mentioning:
- bare repo - aka
git init --bare
- is an option when we want to have an central repo stored somewhere not in github and in our case not as usefull because we want rather forcibly push changes to remote - setup without 3rd parties like github is possible
Here is how can we do it without github
On remote run following:
mkdir ~/github.com/mac2000/demo
cd ~/github.com/mac2000/demo
git init
echo hello > README.md
git add .
git commit -m init
# allow remote push
git config receive.denyCurrentBranch updateInstead
Locally run:
git clone ssh://mini/~/github.com/mac2000/demo
cd demo
echo foo > foo.txt
git add .
git commit -m foo
git push
After push, you should see your changes on remote - profit 🎉
Hooks
pre-receive
There is one more thing we should take care of - if there are some changes on remote - our push will be rejected, and we won't know why, so on remote create following pre-receive hook
touch .git/hooks/pre-receive
chmod +x .git/hooks/pre-receive
vim .git/hooks/pre-receive
#!/usr/bin/env bash
set -e
if [[ $(git --work-tree=.. status --short | wc -l) -ne 0 ]]
then
echo "[pre-receive]: rejecting push beacause of local changes"
exit 1
fi
now, whenever you push changes from your local machine, at least it will be clear what went wrong
notes:
- from pre-receive hook we can not do things like
git clean
,git reset
, ... - pre-receive hook is ran in .git directory, that's why we passing
--work-tree=..
post-receive
Here is second one, for exapmle lets pretend we have node.js project
touch .git/hooks/post-receive
chmod +x .git/hooks/post-receive
vim .git/hooks/post-receive
#!/usr/bin/env bash
set -e
eval "$(/opt/homebrew/bin/brew shellenv)"
echo "[post-receive] npm install"
npm install --quiet --prefix ..
notes:
- hooks will not have your profile, as a result empty
PATH
, so either use full path to executables, or like in this example eval brew env - do not forget about working directory
- stdout and stderr of this script will be printed on local machine while pushing changes 💪
With this in place it is already possible to do whatever tricks we want
github
and for github setup is quite simple
let's pretend we have created github.com/mac2000/demo, so on both machines run
git clone https://github.com/mac2000/demo
to allow remote push, on remote machine, run:
git config receive.denyCurrentBranch updateInstead
then prepare pre-receive and post-receive hooks as described above
on local machine our goal now is to push to two remotes instead of one (github)
to see configured remotes run:
git remote show origin
to add one more remote to origin run:
git remote set-url --add --push origin https://github.com/mac2000/demo
git remote set-url --add --push origin ssh://mini/~/github.com/mac2000/demo
note: we need to run this command twice, because initially this setting is unset and as soon as we ran first one, we will loose github, after running this commands git remote show origin
should display both push urls
and finally try to change, commit, push something - everything is wired up and you should see log similar to this one:
Enumerating objects: 6, done.
Counting objects: 100% (6/6), done.
Delta compression using up to 8 threads
Compressing objects: 100% (4/4), done.
Writing objects: 100% (5/5), 7.66 KiB | 7.66 MiB/s, done.
Total 5 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
remote: [post-receive] npm install # 👈 post-receive script in action
remote:
remote: added 66 packages, and audited 67 packages in 438ms
remote:
remote: 14 packages are looking for funding
remote: run `npm fund` for details
remote:
remote: found 0 vulnerabilities
To ssh://mini/~/github.com/mac2000/demo # 👈 pushed to our remote
6c32237..79b1693 main -> main
Enumerating objects: 6, done.
Counting objects: 100% (6/6), done.
Delta compression using up to 8 threads
Compressing objects: 100% (4/4), done.
Writing objects: 100% (5/5), 7.66 KiB | 7.66 MiB/s, done.
Total 5 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
To https://github.com/mac2000/demo # 👈 pushed to github
6c32237..79b1693 main -> main
troubleshooting
note if push to one origin fails, it won't stop git and it will try to push to others
in such case there is possibility to catch following issue:
- on remote you have some changes
- as result push is prevented
- but push to github succeed
so keep an eye on git push logs
alternatives
in my case, initially, i wanted to have something independed of github/bitbucket/gitlab/... which is kind of solved
there are other ways to configure something similar is you are stick to github
you may want to configure webhook on github side, it will call an endpoint on your server, which will git pull changes
alternativelly you may provide ssh key to github and create some kind of github action that will ssh into your server and do the work
both cases require server to be exposed somehow to external network and obviously you tied up with github
scp, rsync
also there are
scp -r . mini:~/github.com/mac2000/demo
but as you may guess it will be way to slow
alternativelly we may want
rsync -avz --delete --filter=':- .gitignore' -e ssh . mini:~/github.com/mac2000/demo
but at the very end, having git is an ultimate answer to the question - what files are changed on remote
ticks
also, with git hooks we can do all sorts of tricks, e.g.
inf post-receive hook we are receiving oldrev newrev refname stdin which allows us to answer the question - what files were changed, e.g.:
#!/usr/bin/env bash
set -e
while read oldrev newrev refname
do
echo "oldrev: $oldrev"
echo "newrev: $newrev"
echo "refname: $refname"
git diff --name-only "$oldrev" "$newrev"
done
you may want to run your script only for main branch
while read oldrev newrev refname
do
echo "oldrev: $oldrev"
echo "newrev: $newrev"
echo "refname: $refname"
if [ "$refname" = "refs/heads/main" ]
then
echo "main branch pushed - do the workd"
fi
done
for git diff options are:
git diff --name-only "$oldrev" "$newrev"
- list file namesgit diff --name-status "$oldrev" "$newrev"
- list file names prefixed with one letter change code, e.g.A
- added,M
- modified,D
- deletedgit diff --diff-filter=AM --name-only "$oldrev" "$newrev"
- list file names filtered by change, in this example only added or modified files will be printed
let's imagin we have nodejs project served by nginx
so our script may become something like (pseudo)
if changed_files.includes('package.json') {
npm install
}
npm run build
if changed_files.includes('nginx.conf') {
nginx -s reload
}
if changed_files.includes('demo.plist') {
launchctl load ~/Library/LaunchAgents/demo.plist
}
...
also, what's important you are not forced to write your script in bash, here is an example of such script written in node
#!/usr/bin/env /opt/homebrew/bin/node
import { createInterface } from "readline";
import { execSync } from "child_process";
const { oldrev, newrev, refname } = await read();
console.log({ oldrev, newrev, refname });
// remote: {
// remote: oldrev: 'e1be22c77fc76247341a376204d277a75c8b08a5',
// remote: newrev: '2632b65ca1c3be923a25d9bd53bcb8f6d52b9ddc',
// remote: refname: 'refs/heads/main'
// remote: }
const files = changes(oldrev, newrev);
console.log({ files });
// remote: files: [ '.gitignore', 'foo.txt', 'package-lock.json', 'package.json' ]
if (refname === "refs/heads/main" && files.includes("package.json")) {
console.log("package.json changed, running npm install");
try {
execSync("npm install", { stdio: "inherit" });
// remote: /bin/sh: npm: command not found - do not forget about paths, or eval brew
} catch (e) {
console.error("npm install failed", e);
process.exit(1);
}
}
async function readStdin() {
const rl = createInterface({ input: process.stdin, terminal: false });
return new Promise((resolve) => {
const lines = [];
rl.on("line", (line) => lines.push(line));
rl.on("close", () => {
// technically there may be more than one line, aka push with commits to different branches
resolve(lines);
});
});
}
async function read() {
const lines = await readStdin();
const [oldrev, newrev, refname] = lines[0].split(/\s+/);
return { oldrev, newrev, refname };
}
function changes(oldrev, newrev) {
return execSync(`git diff --name-only ${oldrev} ${newrev}`)
.toString()
.trim()
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
}