fix: handle detached / empty local git repo on restart

scarlett
NGPixel 2 weeks ago
parent dbf72551a8
commit d910bae080
No known key found for this signature in database

@ -636,6 +636,28 @@ misconfigured one reads healthy until something is actually asked of it.
has to come off the tree rather than the file: the stem is looked up first and only counts as the has to come off the tree rather than the file: the stem is looked up first and only counts as the
page if `pages.storageFileNameOf` matches the name that went, so a deleted `readme.pdf` never takes page if `pages.storageFileNameOf` matches the name that went, so a deleted `readme.pdf` never takes
the page `readme` with it. the page `readme` with it.
- **A conflict is settled, never left open.** The pull is `--rebase --autostash`, and a rebase that
conflicts stops and waits for a human that this working copy does not have — with the index
unmerged, which git then refuses every write against, so it is not one failed sync but every commit
after it (`ensureRemote`'s checkout is the first thing to fail, and what such a target reports).
`pullRebase` therefore rolls a conflicted rebase back and pulls again with `-X ours`, which during a
rebase is the *remote*, consistent with a pull being authoritative. What `-X` cannot settle is a
file one side changed and the other deleted; that fails the sync, but leaves the working copy usable.
`abortInterrupted` also runs at the top of every sync and in `prepareRepo`, so a copy somebody left
mid-rebase by hand heals itself instead of needing Purge.
- **A working copy started again from nothing re-attaches itself.** It is a directory in a container
while the content is a database elsewhere, so an upgrade that replaces the container without a volume
for `data/repo` takes it with it — and `prepareRepo` then inits an empty one, the next page save
commits into it, and the wiki has the remote's own files under a second root commit. `sync` asks
`sharesHistoryWith` before it pulls, and with no commit in common `reattach` merges the remote's
history in with `--allow-unrelated-histories -X ours` instead: the remote's history and every file
the wiki has not re-saved come back, the working copy's version wins where the two overlap (these
files were written from the database *since* the copy was created, so the remote's copy is the older
one), and the push that follows puts the two in step. It runs in **every** mode — a `push` mode force
push from a working copy created ten minutes ago would otherwise replace the whole repository with
it. The wiki itself is not written to and `applyIncoming` is skipped for that sync, since the diff is
the entire repository; Import Everything is what takes in content the repository has and the wiki
does not.
- **The diff, not the tree.** Incoming changes come from `git diff --name-status -M -z` between the - **The diff, not the tree.** Incoming changes come from `git diff --name-status -M -z` between the
commit the branch was on and the one it is on now — a sync runs every few minutes and cannot read commit the branch was on and the one it is on now — a sync runs every few minutes and cannot read
every file each time. `-z` because a path may contain anything, newlines included. every file each time. `-z` because a path may contain anything, newlines included.

@ -152,6 +152,6 @@ actions:
icon: database-daily-import icon: database-daily-import
purge: purge:
label: Purge Local Repository label: Purge Local Repository
hint: Empty the local working copy and clone it again from the remote. This is the way out of unrelated merge histories or a working copy that git can no longer make sense of. The remote is not touched and nothing is committed. hint: Empty the local working copy and clone it again from the remote. A working copy that was replaced re-attaches itself on the next sync, so this is for when git can no longer make sense of at all. The remote is not touched and nothing is committed.
warn: Any commit that exists only in the local repository and has never been pushed is lost. Run a Force Sync first if you are not sure. warn: Any commit that exists only in the local repository and has never been pushed is lost. Run a Force Sync first if you are not sure.
icon: trash icon: trash

@ -188,6 +188,11 @@ async function prepareRepo(target: StorageTarget): Promise<Repo> {
} }
} }
// -> The working copy outlives any one run of the wiki and anybody with a shell on this machine can
// be in the middle of something in it, so one that arrives mid-rebase is put back before it is
// used rather than left to fail every commit made against it
await abortInterrupted({ git })
// -> Rewritten rather than added to: the URL carries the credentials, so a remote left over from a // -> Rewritten rather than added to: the URL carries the credentials, so a remote left over from a
// previous configuration would still be reachable under its old ones // previous configuration would still be reachable under its old ones
const remotes = await git.getRemotes() const remotes = await git.getRemotes()
@ -279,6 +284,170 @@ async function ensureRemote(repo: Repo, target: StorageTarget): Promise<{ onRemo
return { onRemote } return { onRemote }
} }
/**
* What git has stopped in the middle of, if anything.
*
* A rebase or a merge that hits a conflict is not an operation that failed and ended it is one that
* is still going, waiting for somebody to resolve it, with the index unmerged until they do. Nothing
* here resolves anything, so the only question worth asking is whether the working copy is in that
* state at all.
*/
async function interruptedBy(repo: { git: SimpleGit }): Promise<'rebase' | 'merge' | null> {
const gitDir = await repo.git.revparse(['--absolute-git-dir'])
const exists = (name: string) =>
fs.stat(path.join(gitDir, name)).then(
() => true,
() => false
)
// -> Two directories because there are two rebase backends: `rebase-merge` for the default one and
// `rebase-apply` for `--apply`, which is what an administrator working in the repository by hand
// may well have left behind
if ((await exists('rebase-merge')) || (await exists('rebase-apply'))) {
return 'rebase'
}
return (await exists('MERGE_HEAD')) ? 'merge' : null
}
/**
* Put the working copy back the way it was before an operation git could not finish.
*
* The one state this module must never leave behind. An unfinished rebase holds the index unmerged,
* and git refuses to check out a branch or make a commit against an unmerged index so it is not one
* sync that failed but everything after it: every page save, every upload and every later sync, until
* somebody notices and runs Purge. Aborting restores the branch and the working tree to what they
* were, so nothing local is lost and the next sync simply tries again.
*
* @returns What was rolled back, if anything, so that a report can say it happened
*/
async function abortInterrupted(repo: { git: SimpleGit }): Promise<'rebase' | 'merge' | null> {
const interrupted = await interruptedBy(repo)
if (!interrupted) {
return null
}
WIKI.logger.warn(`(STORAGE/GIT) Rolling back an unfinished ${interrupted}...`)
await repo.git.raw([interrupted, '--abort'])
return interrupted
}
/** The paths a stopped merge or rebase has left unresolved. */
async function unmergedPaths(repo: { git: SimpleGit }): Promise<string[]> {
const raw = await repo.git.raw(['diff', '--name-only', '--diff-filter=U', '-z']).catch(() => '')
return raw.split('\0').filter((p) => p !== '')
}
/**
* Bring the remote's commits in under the wiki's, resolving whatever collides.
*
* A rebase stops at the first conflict and waits for a human, and this one has none: the working copy
* is the wiki's own, nobody is looking at it, and the sync that started it returns to a scheduler. So
* a conflicted rebase is rolled back and the pull run again with the remote winning every file that
* changed on both sides the direction a pull already has here, where what comes back replaces what
* is in the wiki and the version it replaced is still in that page's history.
*
* `-X ours` reads backwards until you remember which way round a rebase is: the wiki's commits are the
* ones being replayed, so *theirs* is the wiki and *ours* is what was pulled in.
*
* What it does not settle is a file one side changed and the other deleted, since there is no version
* of it to prefer. That fails the sync with the working copy put back rather than left half way
* through a rebase, so the wiki carries on committing while somebody decides which of the two is right.
*
* @returns The paths that had to be resolved that way, for the report
*/
async function pullRebase(repo: Repo, branch: string): Promise<string[]> {
// -> `--autostash` for the same reason as the rest of this: a write that was staged but never
// committed would otherwise refuse every pull from here on rather than only this one
const options = ['--rebase', '--autostash']
try {
await repo.git.pull('origin', branch, options)
return []
} catch (err: any) {
const conflicted = await unmergedPaths(repo)
await abortInterrupted(repo).catch((abortErr: any) => {
WIKI.logger.warn(`(STORAGE/GIT) Could not roll the rebase back: ${abortErr.message}`)
return null
})
// -> Nothing unmerged means the pull failed for something resolving conflicts cannot fix — an
// unreachable remote, a refused key, a branch that has gone — and the sync should say so
if (conflicted.length < 1) {
throw err
}
WIKI.logger.warn(
`(STORAGE/GIT) ${conflicted.length} path(s) conflict with origin/${branch}; taking the remote's version...`
)
try {
await repo.git.pull('origin', branch, [...options, '-X', 'ours'])
} catch (retryErr: any) {
const unsettled = await unmergedPaths(repo)
await abortInterrupted(repo).catch(() => null)
if (unsettled.length < 1) {
throw retryErr
}
throw new Error(
`${unsettled.slice(0, 5).join(', ')}${unsettled.length > 5 ? ', ...' : ''} changed here and were deleted on the remote, or the other way round, which nothing can settle on its own. Nothing has been changed either side. Decide which of the two is right - Purge Local Repository takes the remote's answer, Force Sync in Push mode takes the wiki's.`
)
}
return conflicted
}
}
/**
* Whether this working copy's history and the remote branch's have anything at all in common.
*
* They always should, and there is one ordinary way they come not to: the content is a database on one
* machine and the working copy is a directory on another, so a container replaced without a volume for
* that directory leaves the wiki with a repository started again from nothing. `prepareRepo`
* initializes it, the first page save commits into it, and what that produces is a second root the
* same paths as the remote already has, and not one commit in common with them.
*/
async function sharesHistoryWith(repo: Repo, branch: string): Promise<boolean> {
// -> `merge-base` exits non-zero when there is no common commit at all, which simple-git raises
return repo.git
.raw(['merge-base', 'HEAD', `origin/${branch}`])
.then((out) => out.trim().length > 0)
.catch(() => false)
}
/**
* Take the remote's history up as this working copy's own, keeping the files the wiki has written.
*
* What a rebase cannot do, and what lets a replaced working copy heal itself instead of waiting for an
* administrator: the two histories are merged, unrelated as they are, and every file that exists on
* both sides is settled in favour of the working copy. That is the opposite of what `pullRebase` does
* with a conflict, and deliberately these local files were written by page saves made *since* the
* repository was started again, straight out of the wiki's own database, so the remote's copy of one
* of them is by construction the older. The wiki loses nothing it has, the remote's history and every
* file the wiki has not touched since come back, and the push that follows leaves the two in step.
*
* Nothing is written to the wiki. Its database is the half that survived it is the working copy that
* was lost and a scheduled job that started creating pages out of a repository it had only just been
* introduced to would be a surprise, most of all where the wiki had deleted them on purpose. Content
* the repository holds and the wiki does not is what `importAll` is for, which is why the report says
* so rather than acting on it.
*/
async function reattach(repo: Repo, branch: string): Promise<string> {
WIKI.logger.warn(
`(STORAGE/GIT) ${repo.root} has no history in common with origin/${branch}; taking the remote's up...`
)
try {
await repo.git.raw([
'merge',
'--allow-unrelated-histories',
'-X',
'ours',
'--no-edit',
'-m',
`chore: reconcile the working copy with origin/${branch}`,
`origin/${branch}`
])
} catch (err: any) {
await abortInterrupted(repo).catch(() => null)
throw new Error(
`This working copy has no history in common with origin/${branch}, and taking the remote's up failed: ${err.message}. Purge Local Repository starts again from the remote's copy.`
)
}
return `The working copy had no history in common with origin/${branch} - which is what a container replaced without a volume for it leaves behind - so the remote's history was taken up and this wiki's own files kept on top of it. Nothing in the wiki itself was changed: run Import Everything if the repository holds content this wiki does not.`
}
/** Whether the repository's own ignore rules exclude this path. */ /** Whether the repository's own ignore rules exclude this path. */
async function isIgnored(repo: Repo, relPath: string): Promise<boolean> { async function isIgnored(repo: Repo, relPath: string): Promise<boolean> {
try { try {
@ -576,7 +745,18 @@ function describeImport(summary: ImportSummary | null): string {
* **A pull is authoritative.** What it brings in is applied to the wiki, replacing what is there and * **A pull is authoritative.** What it brings in is applied to the wiki, replacing what is there and
* a commit that deleted a file deletes the page or the asset here too, which is the whole point of * a commit that deleted a file deletes the page or the asset here too, which is the whole point of
* pointing a wiki at a repository other people push to. It also means push access to the remote is * pointing a wiki at a repository other people push to. It also means push access to the remote is
* effectively write access to the wiki, which is worth knowing before configuring one. * effectively write access to the wiki, which is worth knowing before configuring one. It is also how
* a file that changed on both sides is settled: the remote wins it, since nobody is here to be asked.
*
* **A sync always leaves the working copy usable**, which is what `abortInterrupted` is for. A stopped
* rebase holds the index unmerged and git then refuses every write against it, so a conflict nobody
* resolves does not cost one sync it costs every commit the wiki makes afterwards.
*
* **A working copy started again from nothing re-attaches itself.** Losing one is ordinary rather than
* exceptional: it is a directory in a container, the content is a database somewhere else, and an
* upgrade that replaces the container without a volume for it takes it with it. What the wiki then has
* is the remote's files under a root commit of its own, so a sync that finds no commit in common takes
* the remote's history up instead of rebasing onto it `reattach`.
* *
* Everything runs through `withRepo`, one operation at a time per target: git locks its index for the * Everything runs through `withRepo`, one operation at a time per target: git locks its index for the
* length of a write, so two concurrent uploads would otherwise have one of them fail outright. * length of a write, so two concurrent uploads would otherwise have one of them fail outright.
@ -716,7 +896,8 @@ const gitStorage: StorageModule = {
* *
* The direction is the target's `syncMode`, and it decides which half runs: `push` never takes * The direction is the target's `syncMode`, and it decides which half runs: `push` never takes
* anything in and force-pushes, so the wiki wins; `pull` never sends anything, so the remote does; * anything in and force-pushes, so the wiki wins; `pull` never sends anything, so the remote does;
* `sync` does both, rebasing the wiki's commits on top of what it pulled. * `sync` does both, rebasing the wiki's commits on top of what it pulled and resolving whatever
* that conflicts on rather than stopping half way through it. See `pullRebase`.
* *
* Whatever a pull brought in is then applied to the wiki created, replaced, or deleted. That is * Whatever a pull brought in is then applied to the wiki created, replaced, or deleted. That is
* done from a `--name-status` diff between the commit the branch was on before and the one it is on * done from a `--name-status` diff between the commit the branch was on before and the one it is on
@ -730,15 +911,29 @@ const gitStorage: StorageModule = {
if (!target.config.repoUrl) { if (!target.config.repoUrl) {
return 'No repository URI is configured, so there is nothing to sync with. Commits are being made locally.' return 'No repository URI is configured, so there is nothing to sync with. Commits are being made locally.'
} }
// -> Before anything else touches the repository, since git refuses to move a branch or make a
// commit while an earlier operation is unfinished — `ensureRemote`'s checkout is the first
// thing to fail, which is why that is what a wedged working copy reports
const recovered = await abortInterrupted(repo)
const { onRemote } = await ensureRemote(repo, target) const { onRemote } = await ensureRemote(repo, target)
const before = await repo.git.revparse(['HEAD']).catch(() => null) const before = await repo.git.revparse(['HEAD']).catch(() => null)
const parts: string[] = [] const parts: string[] = []
// -> Nothing to pull from a branch the remote does not have yet; the push below creates it // -> Nothing to pull from a branch the remote does not have yet; the push below creates it
if (mode !== 'push' && onRemote) { let conflicted: string[] = []
let reattached: string | null = null
if (onRemote && before && !(await sharesHistoryWith(repo, branch))) {
/*
In every mode, including the two that would otherwise not go near the remote's history. A
`push` that forced this working copy onto the remote would replace a wiki's whole repository
with the handful of pages saved since the copy was started again the mode says the wiki is
the authority, and a working copy that has just been created is not the wiki.
*/
reattached = await reattach(repo, branch)
} else if (mode !== 'push' && onRemote) {
WIKI.logger.info(`(STORAGE/GIT) Pulling from origin/${branch}...`) WIKI.logger.info(`(STORAGE/GIT) Pulling from origin/${branch}...`)
await repo.git.pull('origin', branch, ['--rebase']) conflicted = await pullRebase(repo, branch)
} }
if (mode !== 'pull') { if (mode !== 'pull') {
WIKI.logger.info(`(STORAGE/GIT) Pushing to origin/${branch}...`) WIKI.logger.info(`(STORAGE/GIT) Pushing to origin/${branch}...`)
@ -750,7 +945,12 @@ const gitStorage: StorageModule = {
) )
} }
if (mode !== 'push' && onRemote) { if (reattached) {
// -> Emphatically not `applyIncoming`: every file on the remote arrived in this working copy
// just now, so the diff is the entire repository and applying it would rewrite every page
// in the wiki from a copy of itself
parts.push(reattached)
} else if (mode !== 'push' && onRemote) {
const after = await repo.git.revparse(['HEAD']).catch(() => null) const after = await repo.git.revparse(['HEAD']).catch(() => null)
if (!after) { if (!after) {
return 'Synced. The repository has no commits yet.' return 'Synced. The repository has no commits yet.'
@ -763,6 +963,14 @@ const gitStorage: StorageModule = {
} else { } else {
parts.push('Pushed to the remote.') parts.push('Pushed to the remote.')
} }
if (recovered) {
parts.push(`An unfinished ${recovered} left by an earlier sync was rolled back first.`)
}
if (conflicted.length > 0) {
parts.push(
`${conflicted.length} file(s) had changed both here and on the remote, and the remote's version won. A page it replaced keeps its previous version in its history; a file has none.`
)
}
return parts.join(' ') return parts.join(' ')
}) })
}, },

Loading…
Cancel
Save