@ -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 ( ' ' )
} )
} )
} ,
} ,