git-amend
Version:
Easily edit git commit with CLI prompt
229 lines (217 loc) • 7.87 kB
JavaScript
const moment = require('moment')
const { MultiSelect, Select, Snippet, Form } = require('enquirer')
const { exec, execStdin, makeEcho, colors: c } = require('./util')
const format = 'ddd MMM DD HH:mm:ss YYYY Z'
const formatDispay = 'YYYY-MM-DD HH:mm'
let [,, limit] = process.argv
limit = +limit || 10
// %H: commit hash
// %h: abbreviated commit hash
// %an: author name
// %ae: author email
// %ad: author date
// %cn: committer name
// %ce: committer email
// %cd: committer date
// %s: subject
// %n: newline
const setEnv = function ({ name, email, date }) {
return Object.assign(process.env, {
'GIT_AUTHOR_NAME': name,
'GIT_AUTHOR_EMAIL': email,
'GIT_AUTHOR_DATE': date,
'GIT_COMMITTER_NAME': name,
'GIT_COMMITTER_EMAIL': email,
'GIT_COMMITTER_DATE': date
})
}
const itemDisplay = function ({ hs, date, name, subject, sequence }) {
return `${c.yellow`(${hs})`} ${c.bold(date.format(formatDispay))} ${c.green.bold(name)} ${subject}${c.cyan(sequence)}`
}
;(async function () {
if (exec`git status -s -uno`.length) {
c.red.bold.println`You have uncommitted changes`
return
}
try { exec`git rebase --abort` } catch (e) {}
const stdout = exec`git log --format=${'%H%x00%h%x00%p%x00%an%x00%ae%x00%ad%x00%cn%x00%ce%x00%cd%x00%s%x00%b%x00%n'} -z -${limit + 1}`
const ref = {}
const commits = stdout.split('\x00\n\x00').map((log, idx) => {
const [hash, hs, p, name, email, date, cname, cemail, cdate, subject, body] = log.trim().split('\x00')
const parents = p ? p.split(' ') : []
const isMerge = parents.length > 1
return hash ? {
hash,
hs,
parents,
isMerge,
subject,
body,
name,
email,
date: moment(date, format),
cname,
cemail,
cdate: moment(cdate, format),
idx,
selected: false
} : undefined
}).filter(x => x)
commits.forEach(x => { ref[x.hash] = x })
const selectedCommits = (await new MultiSelect({
name: 'value',
message: 'Select commits to change',
footer: 'Please select at least one',
limit,
validate (v) { return !!v.length },
result (names) {
return Object.values(this.map(names))
},
choices: commits.slice(0, -1).map(({ hash, hs, name, date, subject }) => ({
name: `${c.yellow`(${hs})`} ${c.bold(date.format(formatDispay))}`,
hint: `${c.green.bold(name)} ${subject}`,
value: hash
}))
}).run()).map(x => ref[x])
commits.reverse()
selectedCommits.reverse()
selectedCommits.forEach(x => { x.selected = true })
const selectedCommitIdx = commits.findIndex(x => x.selected)
if (selectedCommitIdx < 1) throw Error(`Can't rebase`)
const rebaseIndex = selectedCommitIdx - (commits[selectedCommitIdx].isMerge ? 0 : 1)
const rebaseTarget = commits[rebaseIndex]
const rebaseCommits = commits.slice(rebaseIndex + 1).filter(x => !x.isMerge)
const rebaseString = rebaseCommits.map(({ hs }) => `edit ${hs}`).join('\n')
process.env['GIT_SEQUENCE_EDITOR'] = `${makeEcho(rebaseString)}>`
const mode = {}
const modeName = await new Select({
name: 'mode',
message: 'Select mode',
choices: [
{ name: 'info', message: 'Edit info', hint: `- author,email,message` },
{ name: 'set', message: 'Set date', hint: `- change date individually` },
{ name: 'adjust', message: 'Adjust date', hint: `- add duration to all selected` },
{ name: 'stretch', message: 'Stretch Time', hint: '- change date to fit range' }
]
}).run()
mode[modeName] = true
async function editInfo ({ subject, hs, name, email, date, cname, cemail, cdate, body }, sequence) {
const message = itemDisplay({ hs, date, name, subject, sequence })
body = body.replace(/\n/gm, '\\n')
const to = await new Form({
name: 'commit',
message,
choices: [
{ name: 'name', message: 'Name', initial: name },
{ name: 'email', message: 'Email', initial: email },
{ name: 'subject', message: 'Subject', initial: subject },
{ name: 'body', message: 'Body', initial: body }
]
}).run()
const diff = { name: name !== to.name, email: email !== to.email, message: subject !== to.subject || body !== to.body }
to.body = to.body.replace(/\\n/g, '\n')
return () => {
setEnv(Object.assign(to, { date }))
if (diff.name || diff.email) exec`git commit --amend --no-edit --author="${to.name} <${to.email}>"`
if (diff.message) {
execStdin(`git commit --amend --no-verify --no-edit --file=-`, `${to.subject}${to.body && (`\n\n${to.body}`)}`)
}
exec`git rebase --skip`
}
}
async function askTime (message, date) {
const fn = c.bold`moment` + (date ? '' : `.${c.yellow`duration`}`)
const days = date ? 'date' : 'days'
const template = `${fn}({
seconds: ${c.yellow`\${seconds}`},
minutes: ${c.yellow`\${minutes}`},
hours: ${c.yellow`\${hours}`},
${days}: ${c.yellow`\${${days}}`},
months: ${c.yellow`\${months}`},
years: ${c.yellow`\${years}`}
})`
const values = date ? date.toObject() : {
seconds: 0,
minutes: 0,
hours: 0,
days: 0,
months: 0,
years: 0
}
Object.keys(values).forEach(key => { values[key] += '' })
const validate = v => !isNaN(v)
const fields = Object.keys(values).map(name => ({ name, validate }))
const { values: ret } = await new Snippet({
name: 'Time unit',
message,
fields,
format () { return '' },
values,
template
}).run()
return ret
}
let timeUnit
async function changeTime ({ subject, hs, name, email, date, cname, cemail }, sequence) {
if (mode.set) timeUnit = await askTime(itemDisplay({ hs, date, name, subject, sequence }), date)
else if (!timeUnit) timeUnit = await askTime('Duration to add')
const m = mode.set ? moment(timeUnit) : date.add(timeUnit)
const newDate = m.format(format)
return () => {
setEnv({ name, email, date: newDate })
exec`git commit --amend --no-verify --no-edit --date="${newDate}" --author="${name} <${email}>"`
exec`git rebase --skip`
}
}
const stretchTime = (() => {
const first = selectedCommits[0].date
const last = selectedCommits[selectedCommits.length - 1].date
let adjust
return async (commit) => {
if (!adjust) {
const from = moment(await askTime('Stretch from', first))
const to = moment(await askTime('Stretch to', last))
const diff = (to - from)
adjust = date => {
const ratio = ((date - first) / (last - first)) || 0
return from + (diff * ratio)
}
}
return () => {
const { name, email } = commit
const date = moment(adjust(commit.date)).format(format)
setEnv({ name, email, date })
exec`git commit --amend --no-verify --no-edit --date="${date}" --author="${name} <${email}>"`
exec`git rebase --skip`
}
}
})()
const q = [() => exec`git rebase -i ${rebaseTarget.hash}`]
const { length: selectedCount } = rebaseCommits.filter(x => x.selected)
for (let i = 0, count = 0; i < rebaseCommits.length; i++) {
const commit = rebaseCommits[i]
if (commit.selected) {
const sequence = `(${++count}/${selectedCount})`
q.push(() => c.println`running.. ${c.cyan(sequence)}`)
if (mode.stretch) q.push(await stretchTime(commit))
else if (mode.info) q.push(await editInfo(commit, sequence))
else q.push(await (changeTime)(commit, sequence))
} else {
q.push(() => {
setEnv(commit)
exec`git commit --amend --no-edit`
exec`git rebase --skip`
})
}
}
try {
q.forEach(x => x())
} catch (e) {
exec`git rebase --abort`
throw e
}
c.yellow.bold.println`Done!`
})().catch(e => {
c.red.bold.println(e.toString())
})