addons/isl/src/stackEdit/commitStackState.tsblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {RecordOf} from 'immutable';
b69ab319import type {Hash, RepoPath} from 'shared/types/common';
b69ab3110import type {
b69ab3111 ExportFile,
b69ab3112 ExportStack,
b69ab3113 ImportAction,
b69ab3114 ImportCommit,
b69ab3115 ImportStack,
b69ab3116 Mark,
b69ab3117} from 'shared/types/stack';
b69ab3118import type {AbsorbEdit, AbsorbEditId} from './absorb';
b69ab3119import type {CommitRev, FileFlag, FileMetadata, FileRev, FileStackIndex} from './common';
b69ab3120
b69ab3121import deepEqual from 'fast-deep-equal';
b69ab3122import {Map as ImMap, Set as ImSet, List, Record, Seq, is} from 'immutable';
b69ab3123import {LRU, cachedMethod} from 'shared/LRU';
b69ab3124import {SelfUpdate} from 'shared/immutableExt';
b69ab3125import {firstLine, generatorContains, nullthrows, zip} from 'shared/utils';
b69ab3126import {
b69ab3127 commitMessageFieldsSchema,
b69ab3128 commitMessageFieldsToString,
b69ab3129 mergeCommitMessageFields,
b69ab3130 parseCommitMessageFields,
b69ab3131} from '../CommitInfoView/CommitMessageFields';
b69ab3132import {WDIR_NODE} from '../dag/virtualCommit';
b69ab3133import {t} from '../i18n';
b69ab3134import {readAtom} from '../jotaiUtils';
b69ab3135import {assert} from '../utils';
b69ab3136import {
b69ab3137 calculateAbsorbEditsForFileStack,
b69ab3138 embedAbsorbId,
b69ab3139 extractRevAbsorbId,
b69ab3140 revWithAbsorb,
b69ab3141} from './absorb';
b69ab3142import {
b69ab3143 ABSENT_FILE,
b69ab3144 ABSENT_FLAG,
b69ab3145 Base85,
b69ab3146 CommitIdx,
b69ab3147 CommitState,
b69ab3148 DataRef,
b69ab3149 DateTuple,
b69ab3150 FileIdx,
b69ab3151 FileState,
b69ab3152 isAbsent,
b69ab3153 isContentSame,
b69ab3154 isRename,
b69ab3155 isUtf8,
b69ab3156 toMetadata,
b69ab3157} from './common';
b69ab3158import {FileStackState} from './fileStackState';
b69ab3159import {max, next, prev} from './revMath';
b69ab3160
b69ab3161type CommitStackProps = {
b69ab3162 /**
b69ab3163 * Original stack exported by `debugexportstack`. Immutable.
b69ab3164 * Useful to calculate "predecessor" information.
b69ab3165 */
b69ab3166 originalStack: Readonly<ExportStack>;
b69ab3167
b69ab3168 /**
b69ab3169 * File contents at the bottom of the stack.
b69ab3170 *
b69ab3171 * For example, when editing stack with two commits A and B:
b69ab3172 *
b69ab3173 * ```
b69ab3174 * B <- draft, rev 2
b69ab3175 * |
b69ab3176 * A <- draft, modifies foo.txt, rev 1
b69ab3177 * /
b69ab3178 * P <- public, does not modify foo.txt, rev 0
b69ab3179 * ```
b69ab3180 *
b69ab3181 * `bottomFiles['foo.txt']` would be the `foo.txt` content in P,
b69ab3182 * despite P does not change `foo.txt`.
b69ab3183 *
b69ab3184 * `bottomFiles` are considered immutable - stack editing operations
b69ab3185 * won't change `bottomFiles` directly.
b69ab3186 *
b69ab3187 * This also assumes there are only one root of the stack.
b69ab3188 *
b69ab3189 * This implies that: every file referenced or edited by any commit
b69ab3190 * in the stack will be present in this map. If a file was added
b69ab3191 * later in the stack, it is in this map and marked as absent.
b69ab3192 */
b69ab3193 bottomFiles: Readonly<Map<RepoPath, FileState>>;
b69ab3194
b69ab3195 /**
b69ab3196 * Mutable commit stack. Indexed by rev.
b69ab3197 * Only stores "modified (added, edited, deleted)" files.
b69ab3198 */
b69ab3199 stack: List<CommitState>;
b69ab31100
b69ab31101 /**
b69ab31102 * File stack states.
b69ab31103 * They are constructed on demand, and provide advanced features.
b69ab31104 */
b69ab31105 fileStacks: List<FileStackState>;
b69ab31106
b69ab31107 /**
b69ab31108 * Map from `CommitIdx` (commitRev and path) to `FileIdx` (FileStack index and rev).
b69ab31109 * Note the commitRev could be -1, meaning that `bottomFiles` is used.
b69ab31110 */
b69ab31111 commitToFile: ImMap<CommitIdx, FileIdx>;
b69ab31112
b69ab31113 /**
b69ab31114 * Reverse (swapped key and value) mapping of `commitToFile` mapping.
b69ab31115 * Note the commitRev could be -1, meaning that `bottomFiles` is used.
b69ab31116 */
b69ab31117 fileToCommit: ImMap<FileIdx, CommitIdx>;
b69ab31118
b69ab31119 /**
b69ab31120 * Extra information for absorb.
b69ab31121 *
b69ab31122 * The state might also be calculated from the linelog file stacks
b69ab31123 * (by editing the linelogs, and calculating diffs). It's also tracked here
b69ab31124 * for ease-of-access.
b69ab31125 */
b69ab31126 absorbExtra: AbsorbExtra;
b69ab31127};
b69ab31128
b69ab31129// Factory function for creating instances.
b69ab31130// Its type is the factory function (or the "class type" in OOP sense).
b69ab31131const CommitStackRecord = Record<CommitStackProps>({
b69ab31132 originalStack: [],
b69ab31133 bottomFiles: new Map(),
b69ab31134 stack: List(),
b69ab31135 fileStacks: List(),
b69ab31136 commitToFile: ImMap(),
b69ab31137 fileToCommit: ImMap(),
b69ab31138 absorbExtra: ImMap(),
b69ab31139});
b69ab31140
b69ab31141/**
b69ab31142 * For absorb use-case, each file stack (keyed by the index of fileStacks) has
b69ab31143 * an AbsorbEditId->AbsorbEdit mapping.
b69ab31144 */
b69ab31145type AbsorbExtra = ImMap<FileStackIndex, ImMap<AbsorbEditId, AbsorbEdit>>;
b69ab31146
b69ab31147// Type of *instances* created by the `CommitStackRecord`.
b69ab31148// This makes `CommitStackState` work more like a common OOP `class Foo`:
b69ab31149// `new Foo(...)` is a constructor, and `Foo` is the type of the instances,
b69ab31150// not the constructor or factory.
b69ab31151type CommitStackRecord = RecordOf<CommitStackProps>;
b69ab31152
b69ab31153/**
b69ab31154 * A stack of commits with stack editing features.
b69ab31155 *
b69ab31156 * Provides read write APIs for editing the stack.
b69ab31157 * Under the hood, continuous changes to a same file are grouped
b69ab31158 * to file stacks. Part of analysis and edit operations are delegated
b69ab31159 * to corresponding file stacks.
b69ab31160 */
b69ab31161export class CommitStackState extends SelfUpdate<CommitStackRecord> {
b69ab31162 // Initial setup.
b69ab31163
b69ab31164 /**
b69ab31165 * Construct from an exported stack. For efficient operations,
b69ab31166 * call `.buildFileStacks()` to build up states.
b69ab31167 *
b69ab31168 * `record` initialization is for internal use only.
b69ab31169 */
b69ab31170 constructor(originalStack?: Readonly<ExportStack>, record?: CommitStackRecord) {
b69ab31171 super(
b69ab31172 originalStack !== undefined
b69ab31173 ? CommitStackRecord({
b69ab31174 originalStack,
b69ab31175 bottomFiles: getBottomFilesFromExportStack(originalStack),
b69ab31176 stack: getCommitStatesFromExportStack(originalStack),
b69ab31177 })
b69ab31178 : record !== undefined
b69ab31179 ? record
b69ab31180 : CommitStackRecord(),
b69ab31181 );
b69ab31182 }
b69ab31183
b69ab31184 // Delegates to SelfUpdate.inner
b69ab31185
b69ab31186 get originalStack(): Readonly<ExportStack> {
b69ab31187 return this.inner.originalStack;
b69ab31188 }
b69ab31189
b69ab31190 get bottomFiles(): Readonly<Map<RepoPath, FileState>> {
b69ab31191 return this.inner.bottomFiles;
b69ab31192 }
b69ab31193
b69ab31194 get stack(): List<CommitState> {
b69ab31195 return this.inner.stack;
b69ab31196 }
b69ab31197
b69ab31198 get fileStacks(): List<FileStackState> {
b69ab31199 return this.inner.fileStacks;
b69ab31200 }
b69ab31201
b69ab31202 get commitToFile(): ImMap<CommitIdx, FileIdx> {
b69ab31203 return this.inner.commitToFile;
b69ab31204 }
b69ab31205
b69ab31206 get fileToCommit(): ImMap<FileIdx, CommitIdx> {
b69ab31207 return this.inner.fileToCommit;
b69ab31208 }
b69ab31209
b69ab31210 get absorbExtra(): AbsorbExtra {
b69ab31211 return this.inner.absorbExtra;
b69ab31212 }
b69ab31213
b69ab31214 merge(props: Partial<CommitStackProps>): CommitStackState {
b69ab31215 return new CommitStackState(undefined, this.inner.merge(props));
b69ab31216 }
b69ab31217
b69ab31218 set<K extends keyof CommitStackProps>(key: K, value: CommitStackProps[K]): CommitStackState {
b69ab31219 return new CommitStackState(undefined, this.inner.set(key, value));
b69ab31220 }
b69ab31221
b69ab31222 // Read operations.
b69ab31223
b69ab31224 /** Returns all valid revs. */
b69ab31225 revs(): CommitRev[] {
b69ab31226 return [...this.stack.keys()] as CommitRev[];
b69ab31227 }
b69ab31228
b69ab31229 /** Find the first "Rev" that satisfy the condition. */
b69ab31230 findRev(predicate: (commit: CommitState, rev: CommitRev) => boolean): CommitRev | undefined {
b69ab31231 return this.stack.findIndex(predicate as (commit: CommitState, rev: number) => boolean) as
b69ab31232 | CommitRev
b69ab31233 | undefined;
b69ab31234 }
b69ab31235
b69ab31236 /** Find the last "Rev" that satisfy the condition. */
b69ab31237 findLastRev(predicate: (commit: CommitState, rev: CommitRev) => boolean): CommitRev | undefined {
b69ab31238 return this.stack.findLastIndex(predicate as (commit: CommitState, rev: number) => boolean) as
b69ab31239 | CommitRev
b69ab31240 | undefined;
b69ab31241 }
b69ab31242
b69ab31243 /**
b69ab31244 * Return mutable revs.
b69ab31245 * This filters out public or commits outside the original stack export request.
b69ab31246 */
b69ab31247 mutableRevs(): CommitRev[] {
b69ab31248 return [...this.stack.filter(c => c.immutableKind !== 'hash').map(c => c.rev)];
b69ab31249 }
b69ab31250
b69ab31251 /**
b69ab31252 * Get the file at the given `rev`.
b69ab31253 *
b69ab31254 * Returns `ABSENT_FILE` if the file does not exist in the commit.
b69ab31255 * Throws if the stack does not have information about the path.
b69ab31256 *
b69ab31257 * Note this is different from `this.stack[rev].files.get(path)`,
b69ab31258 * since `files` only tracks modified files, not existing files
b69ab31259 * created from the bottom of the stack.
b69ab31260 *
b69ab31261 * If `rev` is `-1`, check `bottomFiles`.
b69ab31262 */
b69ab31263 getFile(rev: CommitRev, path: RepoPath): FileState {
b69ab31264 if (rev > -1) {
b69ab31265 for (const logRev of this.log(rev)) {
b69ab31266 const commit = this.stack.get(logRev);
b69ab31267 if (commit == null) {
b69ab31268 return ABSENT_FILE;
b69ab31269 }
b69ab31270 const file = commit.files.get(path);
b69ab31271 if (file !== undefined) {
b69ab31272 // Commit modified `file`.
b69ab31273 return file;
b69ab31274 }
b69ab31275 }
b69ab31276 }
b69ab31277 const file = this.bottomFiles.get(path) ?? ABSENT_FILE;
b69ab31278 if (file === undefined) {
b69ab31279 throw new Error(
b69ab31280 `file ${path} is not tracked by stack (tracked files: ${JSON.stringify(
b69ab31281 this.getAllPaths(),
b69ab31282 )})`,
b69ab31283 );
b69ab31284 }
b69ab31285 return file;
b69ab31286 }
b69ab31287
b69ab31288 /**
b69ab31289 * Update a single file without affecting the rest of the stack.
b69ab31290 * Use `getFile` to get the `FileState`.
b69ab31291 *
b69ab31292 * Does some normalization:
b69ab31293 * - If a file is non-empty, then "absent" flag will be ignored.
b69ab31294 * - If a file is absent, then "copyFrom" and other flags will be ignored.
b69ab31295 * - If the "copyFrom" file does not exist in parent, it'll be ignored.
b69ab31296 * - If a file is not newly added, "copyFrom" will be ignored.
b69ab31297 *
b69ab31298 * `rev` cannot be `-1`. `bottomFiles` cannot be modified.
b69ab31299 */
b69ab31300 setFile(rev: CommitRev, path: RepoPath, editFile: (f: FileState) => FileState): CommitStackState {
b69ab31301 if (rev < 0) {
b69ab31302 throw new Error(`invalid rev for setFile: ${rev}`);
b69ab31303 }
b69ab31304 const origFile = this.getFile(rev, path);
b69ab31305 const newFile = editFile(origFile);
b69ab31306 let file = newFile;
b69ab31307 // Remove 'absent' for non-empty files.
b69ab31308 if (isAbsent(file) && this.getUtf8Data(file) !== '') {
b69ab31309 const newFlags: FileFlag = file.flags === ABSENT_FLAG ? '' : (file.flags ?? '');
b69ab31310 file = file.set('flags', newFlags);
b69ab31311 }
b69ab31312 // Remove other flags for absent files.
b69ab31313 if (isAbsent(file) && file.flags !== ABSENT_FLAG) {
b69ab31314 file = file.set('flags', ABSENT_FLAG);
b69ab31315 }
b69ab31316 // Check "copyFrom".
b69ab31317 const copyFrom = file.copyFrom;
b69ab31318 if (copyFrom != null) {
b69ab31319 const p1 = this.singleParentRev(rev) ?? (-1 as CommitRev);
b69ab31320 if (!isAbsent(this.getFile(p1, path))) {
b69ab31321 file = file.remove('copyFrom');
b69ab31322 } else {
b69ab31323 const copyFromFile = this.getFile(p1, copyFrom);
b69ab31324 if (isAbsent(copyFromFile)) {
b69ab31325 file = file.remove('copyFrom');
b69ab31326 }
b69ab31327 }
b69ab31328 }
b69ab31329 let newStack: CommitStackState = this.set(
b69ab31330 'stack',
b69ab31331 this.stack.setIn([rev, 'files', path], file),
b69ab31332 );
b69ab31333 // Adjust "copyFrom" of child commits.
b69ab31334 // If this file is deleted, then child commits cannot copy from it.
b69ab31335 if (isAbsent(file) && !isAbsent(origFile)) {
b69ab31336 newStack.childRevs(rev).forEach(childRev => {
b69ab31337 newStack = newStack.dropCopyFromIf(childRev, (_p, f) => f.copyFrom === path);
b69ab31338 });
b69ab31339 }
b69ab31340 // If this file is added, then the same path in the child commits cannot use copyFrom.
b69ab31341 if (!isAbsent(file) && isAbsent(origFile)) {
b69ab31342 newStack.childRevs(rev).forEach(childRev => {
b69ab31343 newStack = newStack.dropCopyFromIf(childRev, (p, _f) => p === path);
b69ab31344 });
b69ab31345 }
b69ab31346 return newStack;
b69ab31347 }
b69ab31348
b69ab31349 dropCopyFromIf(
b69ab31350 rev: CommitRev,
b69ab31351 predicate: (path: RepoPath, file: FileState) => boolean,
b69ab31352 ): CommitStackState {
b69ab31353 const commit = this.stack.get(rev);
b69ab31354 if (commit == null) {
b69ab31355 return this;
b69ab31356 }
b69ab31357 const newFiles = commit.files.mapEntries(([path, file]) => {
b69ab31358 const newFile = predicate(path, file) ? file.remove('copyFrom') : file;
b69ab31359 return [path, newFile];
b69ab31360 });
b69ab31361 const newStack = this.stack.setIn([rev, 'files'], newFiles);
b69ab31362 return this.set('stack', newStack);
b69ab31363 }
b69ab31364
b69ab31365 childRevs(rev: CommitRev): Array<CommitRev> {
b69ab31366 const result = [];
b69ab31367 for (let i = rev + 1; i < this.stack.size; ++i) {
b69ab31368 if (this.stack.get(i)?.parents?.contains(rev)) {
b69ab31369 result.push(i as CommitRev);
b69ab31370 }
b69ab31371 }
b69ab31372 return result;
b69ab31373 }
b69ab31374
b69ab31375 /**
b69ab31376 * Get a list of paths changed by a commit.
b69ab31377 *
b69ab31378 * If `text` is set to `true`, only return text (content editable) paths.
b69ab31379 * If `text` is set to `false`, only return non-text (not content editable) paths.
b69ab31380 */
b69ab31381 getPaths(rev: CommitRev, props?: {text?: boolean}): RepoPath[] {
b69ab31382 const commit = this.stack.get(rev);
b69ab31383 if (commit == null) {
b69ab31384 return [];
b69ab31385 }
b69ab31386 const text = props?.text;
b69ab31387 const result = [];
b69ab31388 for (const [path, file] of commit.files) {
b69ab31389 if (text != null && isUtf8(file) !== text) {
b69ab31390 continue;
b69ab31391 }
b69ab31392 result.push(path);
b69ab31393 }
b69ab31394 return result.sort();
b69ab31395 }
b69ab31396
b69ab31397 /** Get all file paths ever referred (via "copy from") or changed in the stack. */
b69ab31398 getAllPaths(): RepoPath[] {
b69ab31399 return [...this.bottomFiles.keys()].sort();
b69ab31400 }
b69ab31401
b69ab31402 /** List revs, starting from the given rev. */
b69ab31403 *log(startRev: CommitRev): Generator<CommitRev, void> {
b69ab31404 const toVisit = [startRev];
b69ab31405 while (true) {
b69ab31406 const rev = toVisit.pop();
b69ab31407 if (rev === undefined || rev < 0) {
b69ab31408 break;
b69ab31409 }
b69ab31410 yield rev;
b69ab31411 const commit = this.stack.get(rev);
b69ab31412 if (commit != null) {
b69ab31413 // Visit parent commits.
b69ab31414 commit.parents.forEach(parentRev => {
b69ab31415 assert(parentRev < rev, 'parent rev must < child to prevent infinite loop in log()');
b69ab31416 toVisit.push(parentRev);
b69ab31417 });
b69ab31418 }
b69ab31419 }
b69ab31420 }
b69ab31421
b69ab31422 /**
b69ab31423 * List revs that change the given file, starting from the given rev.
b69ab31424 * Optionally follow renames.
b69ab31425 * Optionally return bottom (rev -1) file.
b69ab31426 */
b69ab31427 *logFile(
b69ab31428 startRev: CommitRev,
b69ab31429 startPath: RepoPath,
b69ab31430 followRenames = false,
b69ab31431 includeBottom = false,
b69ab31432 ): Generator<[CommitRev, RepoPath, FileState], void> {
b69ab31433 let path = startPath;
b69ab31434 let lastFile = undefined;
b69ab31435 let lastPath = path;
b69ab31436 for (const rev of this.log(startRev)) {
b69ab31437 const commit = this.stack.get(rev);
b69ab31438 if (commit == null) {
b69ab31439 continue;
b69ab31440 }
b69ab31441 const file = commit.files.get(path);
b69ab31442 if (file !== undefined) {
b69ab31443 yield [rev, path, file];
b69ab31444 lastFile = file;
b69ab31445 lastPath = path;
b69ab31446 }
b69ab31447 if (followRenames && file?.copyFrom) {
b69ab31448 path = file.copyFrom;
b69ab31449 }
b69ab31450 }
b69ab31451 if (includeBottom && lastFile != null) {
b69ab31452 const bottomFile = this.bottomFiles.get(path);
b69ab31453 if (bottomFile != null && (path !== lastPath || !bottomFile.equals(lastFile))) {
b69ab31454 yield [-1 as CommitRev, path, bottomFile];
b69ab31455 }
b69ab31456 }
b69ab31457 }
b69ab31458
b69ab31459 // "Save changes" related.
b69ab31460
b69ab31461 /**
b69ab31462 * Produce a `ImportStack` useful for the `debugimportstack` command
b69ab31463 * to save changes.
b69ab31464 *
b69ab31465 * Note this function only returns parts that are changed. If nothing is
b69ab31466 * changed, this function might return an empty array.
b69ab31467 *
b69ab31468 * Options:
b69ab31469 * - goto: specify a rev or (old commit) to goto. The rev must be changed
b69ab31470 * otherwise this parameter is ignored.
b69ab31471 * - preserveDirtyFiles: if true, do not change files in the working copy.
b69ab31472 * Under the hood, this changes the "goto" to "reset".
b69ab31473 * - rewriteDate: if set, the unix timestamp (in seconds) for newly
b69ab31474 * created commits.
b69ab31475 * - skipWdir: if set, skip changes for the wdir() virtual commit.
b69ab31476 * This is desirable for operations like absorb, or "amend --to",
b69ab31477 * where the working copy is expected to stay unchanged regardless
b69ab31478 * of the current partial/chunk selection.
b69ab31479 *
b69ab31480 * Example use-cases:
b69ab31481 * - Editing a stack (clean working copy): goto = origCurrentHash
b69ab31482 * - commit -i: create new rev, goto = maxRev, preserveDirtyFiles = true
b69ab31483 * - amend -i, absorb: goto = origCurrentHash, preserveDirtyFiles = true
b69ab31484 */
b69ab31485 calculateImportStack(opts?: {
b69ab31486 goto?: CommitRev | Hash;
b69ab31487 preserveDirtyFiles?: boolean;
b69ab31488 rewriteDate?: number;
b69ab31489 skipWdir?: boolean;
b69ab31490 }): ImportStack {
b69ab31491 // Resolve goto to a Rev.
b69ab31492 // Special case: if it's at the old stack top, use the new stack top instead.
b69ab31493 const gotoRev: CommitRev | undefined =
b69ab31494 typeof opts?.goto === 'string'
b69ab31495 ? this.originalStack.at(-1)?.node == opts.goto
b69ab31496 ? this.stack.last()?.rev
b69ab31497 : this.findLastRev(c => c.originalNodes.has(opts.goto as string))
b69ab31498 : opts?.goto;
b69ab31499
b69ab31500 // Figure out the first changed rev.
b69ab31501 const state = this.useFileContent();
b69ab31502 const originalState = new CommitStackState(state.originalStack);
b69ab31503 const firstChangedRev = state.stack.findIndex((commit, i) => {
b69ab31504 const originalCommit = originalState.stack.get(i);
b69ab31505 return originalCommit == null || !is(commit, originalCommit);
b69ab31506 });
b69ab31507
b69ab31508 // Figure out what commits are changed.
b69ab31509 let changedCommits: CommitState[] =
b69ab31510 firstChangedRev < 0 ? [] : state.stack.slice(firstChangedRev).toArray();
b69ab31511 if (opts?.skipWdir) {
b69ab31512 changedCommits = changedCommits.filter(c => !c.originalNodes.contains(WDIR_NODE));
b69ab31513 }
b69ab31514 const changedRevs: Set<CommitRev> = new Set(changedCommits.map(c => c.rev));
b69ab31515 const revToMark = (rev: CommitRev): Mark => `:r${rev}`;
b69ab31516 const revToMarkOrHash = (rev: CommitRev): Mark | Hash => {
b69ab31517 if (changedRevs.has(rev)) {
b69ab31518 return revToMark(rev);
b69ab31519 } else {
b69ab31520 const nodes = nullthrows(state.stack.get(rev)).originalNodes;
b69ab31521 assert(nodes.size === 1, 'unchanged commits should have exactly 1 nodes');
b69ab31522 return nullthrows(nodes.first());
b69ab31523 }
b69ab31524 };
b69ab31525
b69ab31526 // "commit" new commits based on state.stack.
b69ab31527 const actions: ImportAction[] = changedCommits.map(commit => {
b69ab31528 assert(commit.immutableKind !== 'hash', 'immutable commits should not be changed');
b69ab31529 const newFiles: {[path: RepoPath]: ExportFile | null} = Object.fromEntries(
b69ab31530 [...commit.files.entries()].map(([path, file]) => {
b69ab31531 if (isAbsent(file)) {
b69ab31532 return [path, null];
b69ab31533 }
b69ab31534 const newFile: ExportFile = {};
b69ab31535 if (typeof file.data === 'string') {
b69ab31536 newFile.data = file.data;
b69ab31537 } else if (file.data instanceof Base85) {
b69ab31538 newFile.dataBase85 = file.data.dataBase85;
b69ab31539 } else if (file.data instanceof DataRef) {
b69ab31540 newFile.dataRef = file.data.toJS();
b69ab31541 }
b69ab31542 if (file.copyFrom != null) {
b69ab31543 newFile.copyFrom = file.copyFrom;
b69ab31544 }
b69ab31545 if (file.flags != null) {
b69ab31546 newFile.flags = file.flags;
b69ab31547 }
b69ab31548 return [path, newFile];
b69ab31549 }),
b69ab31550 );
b69ab31551 // Ensure the text is not empty with a filler title.
b69ab31552 const text =
b69ab31553 commit.text.trim().length === 0 ||
b69ab31554 // if a commit template is used, but the title is not given, then we may have non-title text.
b69ab31555 // sl would trim the leading whitespace, which can end up using the commit template as the commit title.
b69ab31556 // Instead, use the same filler title.
b69ab31557 commit.text[0] === '\n'
b69ab31558 ? t('(no title provided)') + commit.text
b69ab31559 : commit.text;
b69ab31560 const importCommit: ImportCommit = {
b69ab31561 mark: revToMark(commit.rev),
b69ab31562 author: commit.author,
b69ab31563 date: [opts?.rewriteDate ?? commit.date.unix, commit.date.tz],
b69ab31564 text,
b69ab31565 parents: commit.parents.toArray().map(revToMarkOrHash),
b69ab31566 predecessors: commit.originalNodes.toArray().filter(n => n !== WDIR_NODE),
b69ab31567 files: newFiles,
b69ab31568 };
b69ab31569 return ['commit', importCommit];
b69ab31570 });
b69ab31571
b69ab31572 // "goto" or "reset" as requested.
b69ab31573 if (gotoRev != null && changedRevs.has(gotoRev)) {
b69ab31574 if (opts?.preserveDirtyFiles) {
b69ab31575 actions.push(['reset', {mark: revToMark(gotoRev)}]);
b69ab31576 } else {
b69ab31577 actions.push(['goto', {mark: revToMark(gotoRev)}]);
b69ab31578 }
b69ab31579 }
b69ab31580
b69ab31581 // "hide" commits that disappear from state.originalStack => state.stack.
b69ab31582 // Only requested mutable commits are considered.
b69ab31583 const coveredNodes: Set<Hash> = state.stack.reduce((acc, commit) => {
b69ab31584 commit.originalNodes.forEach((n: Hash): Set<Hash> => acc.add(n));
b69ab31585 return acc;
b69ab31586 }, new Set<Hash>());
b69ab31587 const orphanedNodes: Hash[] = state.originalStack
b69ab31588 .filter(c => c.requested && !c.immutable && !coveredNodes.has(c.node))
b69ab31589 .map(c => c.node);
b69ab31590 if (orphanedNodes.length > 0) {
b69ab31591 actions.push(['hide', {nodes: orphanedNodes}]);
b69ab31592 }
b69ab31593
b69ab31594 return actions;
b69ab31595 }
b69ab31596
b69ab31597 // File stack related.
b69ab31598
b69ab31599 /**
b69ab31600 * Get the parent version of a file and its introducing rev.
b69ab31601 * If the returned `rev` is -1, it means the file comes from
b69ab31602 * "bottomFiles", aka. its introducing rev is outside the stack.
b69ab31603 */
b69ab31604 parentFile(
b69ab31605 rev: CommitRev,
b69ab31606 path: RepoPath,
b69ab31607 followRenames = true,
b69ab31608 ): [CommitRev, RepoPath, FileState] {
b69ab31609 let prevRev = -1 as CommitRev;
b69ab31610 let prevPath = path;
b69ab31611 let prevFile = nullthrows(this.bottomFiles.get(path));
b69ab31612 const includeBottom = true;
b69ab31613 const logFile = this.logFile(rev, path, followRenames, includeBottom);
b69ab31614 for (const [logRev, logPath, file] of logFile) {
b69ab31615 if (logRev !== rev) {
b69ab31616 [prevRev, prevPath] = [logRev, logPath];
b69ab31617 prevFile = file;
b69ab31618 break;
b69ab31619 }
b69ab31620 }
b69ab31621 return [prevRev, prevPath, prevFile];
b69ab31622 }
b69ab31623
b69ab31624 /** Assert that the revs are in the right order. */
b69ab31625 assertRevOrder() {
b69ab31626 assert(
b69ab31627 this.stack.every(c => c.parents.every(p => p < c.rev)),
b69ab31628 'parent rev should < child rev',
b69ab31629 );
b69ab31630 assert(
b69ab31631 this.stack.every((c, i) => c.rev === i),
b69ab31632 'rev should equal to stack index',
b69ab31633 );
b69ab31634 }
b69ab31635
b69ab31636 // Absorb related {{{
b69ab31637
b69ab31638 /** Check if there is a pending absorb in this stack */
b69ab31639 hasPendingAbsorb(): boolean {
b69ab31640 return !this.inner.absorbExtra.isEmpty();
b69ab31641 }
b69ab31642
b69ab31643 /**
b69ab31644 * Prepare for absorb use-case. Break down "wdir()" edits into the stack
b69ab31645 * with special revs so they can be later moved around.
b69ab31646 * See `calculateAbsorbEditsForFileStack` for details.
b69ab31647 *
b69ab31648 * This function assumes the stack top is "wdir()" to absorb, and the stack
b69ab31649 * bottom is immutable (public()).
b69ab31650 */
b69ab31651 analyseAbsorb(): CommitStackState {
b69ab31652 const stack = this.useFileStack();
b69ab31653 const wdirCommitRev = stack.stack.size - 1;
b69ab31654 assert(wdirCommitRev > 0, 'stack cannot be empty');
b69ab31655 let newFileStacks = stack.fileStacks;
b69ab31656 let absorbExtra: AbsorbExtra = ImMap();
b69ab31657 stack.fileStacks.forEach((fileStack, fileIdx) => {
b69ab31658 const topFileRev = prev(fileStack.revLength);
b69ab31659 if (topFileRev < 0) {
b69ab31660 // Empty file stack. Skip.
b69ab31661 return;
b69ab31662 }
b69ab31663 const rev = stack.fileToCommit.get(FileIdx({fileIdx, fileRev: topFileRev}))?.rev;
b69ab31664 if (rev != wdirCommitRev) {
b69ab31665 // wdir() did not change this file. Skip.
b69ab31666 return;
b69ab31667 }
b69ab31668 const [newFileStack, absorbMap] = calculateAbsorbEditsForFileStack(fileStack, {
b69ab31669 fileStackIndex: fileIdx,
b69ab31670 });
b69ab31671 absorbExtra = absorbExtra.set(fileIdx, absorbMap);
b69ab31672 newFileStacks = newFileStacks.set(fileIdx, newFileStack);
b69ab31673 });
b69ab31674 const newStackInner = stack.inner.set('fileStacks', newFileStacks);
b69ab31675 const newStack = new CommitStackState(undefined, newStackInner).set('absorbExtra', absorbExtra);
b69ab31676 return newStack;
b69ab31677 }
b69ab31678
b69ab31679 /**
b69ab31680 * For an absorb edit, defined by `fileIdx`, and `absorbEditId`, return the
b69ab31681 * currently selected and possible "absorb into" commit revs.
b69ab31682 *
b69ab31683 * The edit can be looked up from the `absorbExtra` state.
b69ab31684 */
b69ab31685 getAbsorbCommitRevs(
b69ab31686 fileIdx: number,
b69ab31687 absorbEditId: AbsorbEditId,
b69ab31688 ): {candidateRevs: ReadonlyArray<CommitRev>; selectedRev?: CommitRev} {
b69ab31689 const fileStack = nullthrows(this.fileStacks.get(fileIdx));
b69ab31690 const edit = nullthrows(this.absorbExtra.get(fileIdx)?.get(absorbEditId));
b69ab31691 const toCommitRev = (fileRev: FileRev | null | undefined): CommitRev | undefined => {
b69ab31692 if (fileRev == null) {
b69ab31693 return undefined;
b69ab31694 }
b69ab31695 return this.fileToCommit.get(FileIdx({fileIdx, fileRev}))?.rev;
b69ab31696 };
b69ab31697 // diffChunk uses fileRev, map it to commitRev.
b69ab31698 const selectedRev = toCommitRev(edit.selectedRev);
b69ab31699 const startCandidateFileRev = Math.max(1, edit.introductionRev); // skip file rev 0 (bottomFiles)
b69ab31700 const endCandidateFileRev = fileStack.revLength;
b69ab31701 const candidateRevs: CommitRev[] = [];
b69ab31702 for (let fileRev = startCandidateFileRev; fileRev <= endCandidateFileRev; ++fileRev) {
b69ab31703 const rev = toCommitRev(fileRev as FileRev);
b69ab31704 // Skip immutable (public) commits.
b69ab31705 if (rev != null && this.get(rev)?.immutableKind !== 'hash') {
b69ab31706 candidateRevs.push(rev);
b69ab31707 }
b69ab31708 }
b69ab31709 return {selectedRev, candidateRevs};
b69ab31710 }
b69ab31711
b69ab31712 /**
b69ab31713 * Filter `absorbExtra` by commit rev.
b69ab31714 *
b69ab31715 * Only returns a subset of `absorbExtra` that has the `rev` selected.
b69ab31716 */
b69ab31717 absorbExtraByCommitRev(rev: CommitRev): AbsorbExtra {
b69ab31718 const commit = this.get(rev);
b69ab31719 const isWdir = commit?.originalNodes.contains(WDIR_NODE);
b69ab31720 return ImMap<FileStackIndex, ImMap<AbsorbEditId, AbsorbEdit>>().withMutations(mut => {
b69ab31721 let result = mut;
b69ab31722 this.absorbExtra.forEach((edits, fileStackIndex) => {
b69ab31723 edits.forEach((edit, editId) => {
b69ab31724 assert(edit.absorbEditId === editId, 'absorbEditId should match its map key');
b69ab31725 const fileRev = edit.selectedRev;
b69ab31726 const fileIdx = edit.fileStackIndex;
b69ab31727 const selectedCommitRev =
b69ab31728 fileRev != null &&
b69ab31729 fileIdx != null &&
b69ab31730 this.fileToCommit.get(FileIdx({fileIdx, fileRev}))?.rev;
b69ab31731 if (selectedCommitRev === rev || (edit.selectedRev == null && isWdir)) {
b69ab31732 if (!result.has(fileStackIndex)) {
b69ab31733 result = result.set(fileStackIndex, ImMap<AbsorbEditId, AbsorbEdit>());
b69ab31734 }
b69ab31735 result = result.setIn([fileStackIndex, editId], edit);
b69ab31736 }
b69ab31737 });
b69ab31738 });
b69ab31739 return result;
b69ab31740 });
b69ab31741 }
b69ab31742
b69ab31743 /**
b69ab31744 * Calculates the "candidateRevs" for all absorb edits.
b69ab31745 *
b69ab31746 * For example, in a 26-commit stack A..Z, only C and K changes a.txt, E and J
b69ab31747 * changes b.txt. When the user wants to absorb changes from a.txt and b.txt,
b69ab31748 * we only show 4 relevant commits: C, E, J, K.
b69ab31749 *
b69ab31750 * This function does not report public commits.
b69ab31751 */
b69ab31752 getAllAbsorbCandidateCommitRevs(): Set<CommitRev> {
b69ab31753 const result = new Set<CommitRev>();
b69ab31754 this.absorbExtra.forEach((edits, fileIdx) => {
b69ab31755 edits.forEach((_edit, absorbEditId) => {
b69ab31756 this.getAbsorbCommitRevs(fileIdx, absorbEditId)?.candidateRevs.forEach(rev => {
b69ab31757 if (this.get(rev)?.immutableKind !== 'hash') {
b69ab31758 result.add(rev);
b69ab31759 }
b69ab31760 });
b69ab31761 });
b69ab31762 });
b69ab31763 return result;
b69ab31764 }
b69ab31765
b69ab31766 /**
b69ab31767 * Set `rev` as the "target commit" (amend --to) of an "absorb edit".
b69ab31768 * Happens when the user moves the absorb edit among candidate commits.
b69ab31769 *
b69ab31770 * Throws if the edit cannot be fulfilled, for example, the `commitRev` is
b69ab31771 * before the commit introducing the change (conflict), or if the `commitRev`
b69ab31772 * does not touch the file being edited (current limitation, might be lifted).
b69ab31773 */
b69ab31774 setAbsorbEditDestination(
b69ab31775 fileIdx: number,
b69ab31776 absorbEditId: AbsorbEditId,
b69ab31777 commitRev: CommitRev,
b69ab31778 ): CommitStackState {
b69ab31779 assert(this.hasPendingAbsorb(), 'stack is not prepared for absorb');
b69ab31780 const fileStack = nullthrows(this.fileStacks.get(fileIdx));
b69ab31781 const edit = nullthrows(this.absorbExtra.get(fileIdx)?.get(absorbEditId));
b69ab31782 const selectedFileRev = edit.selectedRev;
b69ab31783 if (selectedFileRev != null) {
b69ab31784 const currentCommitRev = this.fileToCommit.get(
b69ab31785 FileIdx({fileIdx, fileRev: selectedFileRev}),
b69ab31786 )?.rev;
b69ab31787 if (currentCommitRev === commitRev) {
b69ab31788 // No need to edit.
b69ab31789 return this;
b69ab31790 }
b69ab31791 }
b69ab31792 // Figure out the "file rev" from "commit rev", since we don't know the
b69ab31793 // "path" of the file at the "commitRev", for now, we just naively looks up
b69ab31794 // the fileRev one by one... for now
b69ab31795 for (let fileRev = max(edit.introductionRev, 1); ; fileRev = next(fileRev)) {
b69ab31796 const candidateCommitRev = this.fileToCommit.get(FileIdx({fileIdx, fileRev}))?.rev;
b69ab31797 if (candidateCommitRev == null) {
b69ab31798 break;
b69ab31799 }
b69ab31800 if (candidateCommitRev === commitRev) {
b69ab31801 // Update linelog to move the edit to "fileRev".
b69ab31802 const newFileRev = embedAbsorbId(fileRev, absorbEditId);
b69ab31803 const newFileStack = fileStack.remapRevs(rev =>
b69ab31804 !Number.isInteger(rev) && extractRevAbsorbId(rev)[1] === absorbEditId ? newFileRev : rev,
b69ab31805 );
b69ab31806 // Update the absorb extra too.
b69ab31807 const newEdit = edit.set('selectedRev', fileRev);
b69ab31808 const newAbsorbExtra = this.absorbExtra.setIn([fileIdx, absorbEditId], newEdit);
b69ab31809 // It's possible that "wdir()" is all absorbed, the new stack is
b69ab31810 // shorter than the original stack. So we bypass the length check.
b69ab31811 const newStack = this.setFileStackInternal(fileIdx, newFileStack).set(
b69ab31812 'absorbExtra',
b69ab31813 newAbsorbExtra,
b69ab31814 );
b69ab31815 return newStack;
b69ab31816 }
b69ab31817 }
b69ab31818 throw new Error('setAbsorbIntoRev did not find corresponding commit to absorb');
b69ab31819 }
b69ab31820
b69ab31821 /**
b69ab31822 * Apply pending absorb edits.
b69ab31823 *
b69ab31824 * After this, absorb edits can no longer be edited by `setAbsorbEditDestination`,
b69ab31825 * `hasPendingAbsorb()` returns `false`, and `calculateImportStack()` can be used.
b69ab31826 */
b69ab31827 applyAbsorbEdits(): CommitStackState {
b69ab31828 if (!this.hasPendingAbsorb()) {
b69ab31829 return this;
b69ab31830 }
b69ab31831 return this.useFileContent().set('absorbExtra', ImMap());
b69ab31832 }
b69ab31833
b69ab31834 // }}} (absorb related)
b69ab31835
b69ab31836 /**
b69ab31837 * (Re-)build file stacks and mappings.
b69ab31838 *
b69ab31839 * If `followRenames` is true, then attempt to follow renames
b69ab31840 * when building linelogs (default: true).
b69ab31841 */
b69ab31842 buildFileStacks(opts?: BuildFileStackOptions): CommitStackState {
b69ab31843 const fileStacks: FileStackState[] = [];
b69ab31844 let commitToFile = ImMap<CommitIdx, FileIdx>();
b69ab31845 let fileToCommit = ImMap<FileIdx, CommitIdx>();
b69ab31846
b69ab31847 const followRenames = opts?.followRenames ?? true;
b69ab31848
b69ab31849 this.assertRevOrder();
b69ab31850
b69ab31851 const processFile = (
b69ab31852 state: CommitStackState,
b69ab31853 rev: CommitRev,
b69ab31854 file: FileState,
b69ab31855 path: RepoPath,
b69ab31856 ) => {
b69ab31857 const [prevRev, prevPath, prevFile] = state.parentFile(rev, path, followRenames);
b69ab31858 if (isUtf8(file)) {
b69ab31859 // File was added or modified and has utf-8 content.
b69ab31860 let fileAppended = false;
b69ab31861 if (prevRev >= 0) {
b69ab31862 // Try to reuse an existing file stack.
b69ab31863 const prev = commitToFile.get(CommitIdx({rev: prevRev, path: prevPath}));
b69ab31864 if (prev) {
b69ab31865 const prevFileStack = fileStacks[prev.fileIdx];
b69ab31866 // File stack history is linear. Only reuse it if its last
b69ab31867 // rev matches `prevFileRev`
b69ab31868 if (prevFileStack.source.revLength === prev.fileRev + 1) {
b69ab31869 const fileRev = next(prev.fileRev);
b69ab31870 fileStacks[prev.fileIdx] = prevFileStack.editText(
b69ab31871 fileRev,
b69ab31872 state.getUtf8Data(file),
b69ab31873 false,
b69ab31874 );
b69ab31875 const cIdx = CommitIdx({rev, path});
b69ab31876 const fIdx = FileIdx({fileIdx: prev.fileIdx, fileRev});
b69ab31877 commitToFile = commitToFile.set(cIdx, fIdx);
b69ab31878 fileToCommit = fileToCommit.set(fIdx, cIdx);
b69ab31879 fileAppended = true;
b69ab31880 }
b69ab31881 }
b69ab31882 }
b69ab31883 if (!fileAppended) {
b69ab31884 // Cannot reuse an existing file stack. Create a new file stack.
b69ab31885 const fileIdx = fileStacks.length;
b69ab31886 let fileTextList = [state.getUtf8Data(file)];
b69ab31887 let fileRev = 0 as FileRev;
b69ab31888 if (isUtf8(prevFile)) {
b69ab31889 // Use "prevFile" as rev 0 (immutable public).
b69ab31890 fileTextList = [state.getUtf8Data(prevFile), ...fileTextList];
b69ab31891 const cIdx = CommitIdx({rev: prevRev, path: prevPath});
b69ab31892 const fIdx = FileIdx({fileIdx, fileRev});
b69ab31893 commitToFile = commitToFile.set(cIdx, fIdx);
b69ab31894 fileToCommit = fileToCommit.set(fIdx, cIdx);
b69ab31895 fileRev = 1 as FileRev;
b69ab31896 }
b69ab31897 const fileStack = new FileStackState(fileTextList);
b69ab31898 fileStacks.push(fileStack);
b69ab31899 const cIdx = CommitIdx({rev, path});
b69ab31900 const fIdx = FileIdx({fileIdx, fileRev});
b69ab31901 commitToFile = commitToFile.set(cIdx, fIdx);
b69ab31902 fileToCommit = fileToCommit.set(fIdx, cIdx);
b69ab31903 }
b69ab31904 }
b69ab31905 };
b69ab31906
b69ab31907 // Migrate off 'fileStack' type, since we are going to replace the file stacks.
b69ab31908 const state = this.useFileContent();
b69ab31909
b69ab31910 state.stack.forEach((commit, revNumber) => {
b69ab31911 const rev = revNumber as CommitRev;
b69ab31912 const files = commit.files;
b69ab31913 // Process order: renames, non-copy, copies.
b69ab31914 const priorityFiles: [number, RepoPath, FileState][] = [...files.entries()].map(
b69ab31915 ([path, file]) => {
b69ab31916 const priority =
b69ab31917 followRenames && isRename(commit, path) ? 0 : file.copyFrom == null ? 1 : 2;
b69ab31918 return [priority, path, file];
b69ab31919 },
b69ab31920 );
b69ab31921 const renamed = new Set<RepoPath>();
b69ab31922 priorityFiles
b69ab31923 .sort(([aPri, aPath, _aFile], [bPri, bPath, _bFile]) =>
b69ab31924 aPri < bPri || (aPri === bPri && aPath < bPath) ? -1 : 1,
b69ab31925 )
b69ab31926 .forEach(([priority, path, file]) => {
b69ab31927 // Skip already "renamed" absent files.
b69ab31928 let skip = false;
b69ab31929 if (priority === 0 && file.copyFrom != null) {
b69ab31930 renamed.add(file.copyFrom);
b69ab31931 } else {
b69ab31932 skip = isAbsent(file) && renamed.has(path);
b69ab31933 }
b69ab31934 if (!skip) {
b69ab31935 processFile(state, rev, file, path);
b69ab31936 }
b69ab31937 });
b69ab31938 });
b69ab31939
b69ab31940 return state.merge({
b69ab31941 fileStacks: List(fileStacks),
b69ab31942 commitToFile,
b69ab31943 fileToCommit,
b69ab31944 });
b69ab31945 }
b69ab31946
b69ab31947 /**
b69ab31948 * Build file stacks if it's not present.
b69ab31949 * This is part of the `useFileStack` implementation detail.
b69ab31950 * It does not ensure the file stack references are actually used for `getFile`.
b69ab31951 * For public API, use `useFileStack` instead.
b69ab31952 */
b69ab31953 private maybeBuildFileStacks(opts?: BuildFileStackOptions): CommitStackState {
b69ab31954 return this.fileStacks.size === 0 ? this.buildFileStacks(opts) : this;
b69ab31955 }
b69ab31956
b69ab31957 /**
b69ab31958 * Switch file contents to use FileStack as source of truth.
b69ab31959 * Useful when using FileStack to edit files.
b69ab31960 */
b69ab31961 useFileStack(): CommitStackState {
b69ab31962 const state = this.maybeBuildFileStacks();
b69ab31963 return state.updateEachFile((rev, file, path) => {
b69ab31964 if (typeof file.data === 'string') {
b69ab31965 const index = state.commitToFile.get(CommitIdx({rev, path}));
b69ab31966 if (index != null) {
b69ab31967 return file.set('data', index);
b69ab31968 }
b69ab31969 }
b69ab31970 return file;
b69ab31971 });
b69ab31972 }
b69ab31973
b69ab31974 /**
b69ab31975 * Switch file contents to use string as source of truth.
b69ab31976 * Useful when rebuilding FileStack.
b69ab31977 */
b69ab31978 useFileContent(): CommitStackState {
b69ab31979 return this.updateEachFile((_rev, file) => {
b69ab31980 if (typeof file.data !== 'string' && isUtf8(file)) {
b69ab31981 const data = this.getUtf8Data(file);
b69ab31982 return file.set('data', data);
b69ab31983 }
b69ab31984 return file;
b69ab31985 }).merge({
b69ab31986 fileStacks: List(),
b69ab31987 commitToFile: ImMap(),
b69ab31988 fileToCommit: ImMap(),
b69ab31989 });
b69ab31990 }
b69ab31991
b69ab31992 /**
b69ab31993 * Iterate through all changed files via the given function.
b69ab31994 */
b69ab31995 updateEachFile(
b69ab31996 func: (commitRev: CommitRev, file: FileState, path: RepoPath) => FileState,
b69ab31997 ): CommitStackState {
b69ab31998 const newStack = this.stack.map(commit => {
b69ab31999 const newFiles = commit.files.map((file, path) => {
b69ab311000 return func(commit.rev, file, path);
b69ab311001 });
b69ab311002 return commit.set('files', newFiles);
b69ab311003 });
b69ab311004 return this.set('stack', newStack);
b69ab311005 }
b69ab311006
b69ab311007 /**
b69ab311008 * Describe all file stacks for testing purpose.
b69ab311009 * Each returned string represents a file stack.
b69ab311010 *
b69ab311011 * Output in `rev:commit/path(content)` format.
b69ab311012 * If `(content)` is left out it means the file at the rev is absent.
b69ab311013 * If `commit` is `.` then it comes from `bottomFiles` meaning that
b69ab311014 * the commit last modifies the path might be outside the stack.
b69ab311015 *
b69ab311016 * Rev 0 is usually the "public" version that is not editable.
b69ab311017 *
b69ab311018 * For example, `0:./x.txt 1:A/x.txt(33) 2:B/y.txt(33)` means:
b69ab311019 * commit A added `x.txt` with the content `33`, and commit B renamed it to
b69ab311020 * `y.txt`.
b69ab311021 *
b69ab311022 * `0:./z.txt(11) 1:A/z.txt(22) 2:C/z.txt` means: `z.txt` existed at
b69ab311023 * the bottom of the stack with the content `11`. Commit A modified
b69ab311024 * its content to `22` and commit C deleted `z.txt`.
b69ab311025 */
b69ab311026 describeFileStacks(showContent = true): string[] {
b69ab311027 const state = this.useFileStack();
b69ab311028 const fileToCommit = state.fileToCommit;
b69ab311029 const stack = state.stack;
b69ab311030 const hasAbsorb = state.hasPendingAbsorb();
b69ab311031 return state.fileStacks
b69ab311032 .map((fileStack, fileIdx) => {
b69ab311033 return fileStack
b69ab311034 .revs()
b69ab311035 .map(fileRev => {
b69ab311036 const value = fileToCommit.get(FileIdx({fileIdx, fileRev}));
b69ab311037 const spans = [`${fileRev}:`];
b69ab311038 assert(
b69ab311039 value != null,
b69ab311040 `fileToCommit should have all file stack revs (missing: fileIdx=${fileIdx} fileRev=${fileRev})`,
b69ab311041 );
b69ab311042 const {rev, path} = value;
b69ab311043 const [commitTitle, absent] =
b69ab311044 rev < 0
b69ab311045 ? ['.', isAbsent(state.bottomFiles.get(path))]
b69ab311046 : ((c: CommitState): [string, boolean] => [
b69ab311047 c.text.split('\n').at(0) || [...c.originalNodes].at(0) || '?',
b69ab311048 isAbsent(c.files.get(path)),
b69ab311049 ])(nullthrows(stack.get(rev)));
b69ab311050 spans.push(`${commitTitle}/${path}`);
b69ab311051 if (showContent && !absent) {
b69ab311052 let content = fileStack.getRev(fileRev).replaceAll('\n', '↵');
b69ab311053 if (hasAbsorb) {
b69ab311054 const absorbedContent = fileStack
b69ab311055 .getRev(revWithAbsorb(fileRev))
b69ab311056 .replaceAll('\n', '↵');
b69ab311057 if (absorbedContent !== content) {
b69ab311058 content += `;absorbed:${absorbedContent}`;
b69ab311059 }
b69ab311060 }
b69ab311061 spans.push(`(${content})`);
b69ab311062 }
b69ab311063 return spans.join('');
b69ab311064 })
b69ab311065 .join(' ');
b69ab311066 })
b69ab311067 .toArray();
b69ab311068 }
b69ab311069
b69ab311070 /** File name for `fileStacks[index]`. If the file is renamed, return */
b69ab311071 getFileStackDescription(fileIdx: number): string {
b69ab311072 const fileStack = nullthrows(this.fileStacks.get(fileIdx));
b69ab311073 const revLength = prev(fileStack.revLength);
b69ab311074 const nameAtFirstRev = this.getFileStackPath(fileIdx, 0 as FileRev);
b69ab311075 const nameAtLastRev = this.getFileStackPath(fileIdx, prev(revLength));
b69ab311076 const words = [];
b69ab311077 if (nameAtFirstRev) {
b69ab311078 words.push(nameAtFirstRev);
b69ab311079 }
b69ab311080 if (nameAtLastRev && nameAtLastRev !== nameAtFirstRev) {
b69ab311081 // U+2192. Rightwards Arrow (Unicode 1.1).
b69ab311082 words.push('→');
b69ab311083 words.push(nameAtLastRev);
b69ab311084 }
b69ab311085 if (revLength > 1) {
b69ab311086 words.push(t('(edited by $n commits)', {replace: {$n: revLength.toString()}}));
b69ab311087 }
b69ab311088 return words.join(' ');
b69ab311089 }
b69ab311090
b69ab311091 /** Get the path name for a specific revision in the given file stack. */
b69ab311092 getFileStackPath(fileIdx: number, fileRev: FileRev): string | undefined {
b69ab311093 return this.fileToCommit.get(FileIdx({fileIdx, fileRev}))?.path;
b69ab311094 }
b69ab311095
b69ab311096 /**
b69ab311097 * Get the commit from a file stack revision.
b69ab311098 * Returns undefined when rev is out of range, or the commit is "public" (ex. fileRev is 0).
b69ab311099 */
b69ab311100 getCommitFromFileStackRev(fileIdx: number, fileRev: FileRev): CommitState | undefined {
b69ab311101 const commitRev = this.fileToCommit.get(FileIdx({fileIdx, fileRev}))?.rev;
b69ab311102 if (commitRev == null || commitRev < 0) {
b69ab311103 return undefined;
b69ab311104 }
b69ab311105 return nullthrows(this.stack.get(commitRev));
b69ab311106 }
b69ab311107
b69ab311108 /**
b69ab311109 * Test if a file rev is "absent". An absent file is different from an empty file.
b69ab311110 */
b69ab311111 isAbsentFromFileStackRev(fileIdx: number, fileRev: FileRev): boolean {
b69ab311112 const commitIdx = this.fileToCommit.get(FileIdx({fileIdx, fileRev}));
b69ab311113 if (commitIdx == null) {
b69ab311114 return true;
b69ab311115 }
b69ab311116 const {rev, path} = commitIdx;
b69ab311117 const file = rev < 0 ? this.bottomFiles.get(path) : this.getFile(rev, path);
b69ab311118 return file == null || isAbsent(file);
b69ab311119 }
b69ab311120
b69ab311121 /**
b69ab311122 * Extract utf-8 data from a file.
b69ab311123 * Pending absorb is applied if considerPendingAbsorb is true.
b69ab311124 */
b69ab311125 getUtf8Data(file: FileState, considerPendingAbsorb = true): string {
b69ab311126 if (typeof file.data === 'string') {
b69ab311127 return file.data;
b69ab311128 }
b69ab311129 if (file.data instanceof FileIdx) {
b69ab311130 let fileRev = file.data.fileRev;
b69ab311131 if (considerPendingAbsorb && this.hasPendingAbsorb()) {
b69ab311132 fileRev = revWithAbsorb(fileRev);
b69ab311133 }
b69ab311134 return nullthrows(this.fileStacks.get(file.data.fileIdx)).getRev(fileRev);
b69ab311135 } else {
b69ab311136 throw new Error('getUtf8Data called on non-utf8 file.');
b69ab311137 }
b69ab311138 }
b69ab311139
b69ab311140 /** Similar to `getUtf8Data`, but returns `null` if not utf-8 */
b69ab311141 getUtf8DataOptional(file: FileState, considerPendingAbsorb = true): string | null {
b69ab311142 return isUtf8(file) ? this.getUtf8Data(file, considerPendingAbsorb) : null;
b69ab311143 }
b69ab311144
b69ab311145 /** Test if two files have the same data. */
b69ab311146 isEqualFile(a: FileState, b: FileState): boolean {
b69ab311147 if ((a.flags ?? '') !== (b.flags ?? '')) {
b69ab311148 return false;
b69ab311149 }
b69ab311150 if (isUtf8(a) && isUtf8(b)) {
b69ab311151 return this.getUtf8Data(a) === this.getUtf8Data(b);
b69ab311152 }
b69ab311153 // We assume base85 data is immutable, non-utf8 so they won't match utf8 data.
b69ab311154 if (a.data instanceof Base85 && b.data instanceof Base85) {
b69ab311155 return a.data.dataBase85 === b.data.dataBase85;
b69ab311156 }
b69ab311157 if (a.data instanceof DataRef && b.data instanceof DataRef) {
b69ab311158 return is(a.data, b.data);
b69ab311159 }
b69ab311160 return false;
b69ab311161 }
b69ab311162
b69ab311163 /** Test if the stack is linear. */
b69ab311164 isStackLinear(): boolean {
b69ab311165 return this.stack.every(
b69ab311166 (commit, rev) =>
b69ab311167 rev === 0 || (commit.parents.size === 1 && commit.parents.first() === rev - 1),
b69ab311168 );
b69ab311169 }
b69ab311170
b69ab311171 /** Find a commit by key. */
b69ab311172 findCommitByKey(key: string): CommitState | undefined {
b69ab311173 return this.stack.find(c => c.key === key);
b69ab311174 }
b69ab311175
b69ab311176 /** Get a specified commit. */
b69ab311177 get(rev: CommitRev): CommitState | undefined {
b69ab311178 return this.stack.get(rev);
b69ab311179 }
b69ab311180
b69ab311181 /** Get the stack size. */
b69ab311182 get size(): number {
b69ab311183 return this.stack.size;
b69ab311184 }
b69ab311185
b69ab311186 // Histedit-related operations.
b69ab311187
b69ab311188 /**
b69ab311189 * Calculate the dependencies of revisions.
b69ab311190 * For example, `{5: [3, 1]}` means rev 5 depends on rev 3 and rev 1.
b69ab311191 *
b69ab311192 * This is used to detect what's reasonable when reordering and dropping
b69ab311193 * commits. For example, if rev 3 depends on rev 2, then rev 3 cannot be
b69ab311194 * moved to be an ancestor of rev 2, and rev 2 cannot be dropped alone.
b69ab311195 */
b69ab311196 calculateDepMap = cachedMethod(this.calculateDepMapImpl, {cache: calculateDepMapCache});
b69ab311197 private calculateDepMapImpl(): Readonly<Map<CommitRev, Set<CommitRev>>> {
b69ab311198 const state = this.useFileStack();
b69ab311199 const depMap = new Map<CommitRev, Set<CommitRev>>(state.stack.map(c => [c.rev, new Set()]));
b69ab311200
b69ab311201 const fileIdxRevToCommitRev = (fileIdx: FileStackIndex, fileRev: FileRev): CommitRev =>
b69ab311202 nullthrows(state.fileToCommit.get(FileIdx({fileIdx, fileRev}))).rev;
b69ab311203
b69ab311204 // Ask FileStack for dependencies about content edits.
b69ab311205 state.fileStacks.forEach((fileStack, fileIdx) => {
b69ab311206 const fileDepMap = fileStack.calculateDepMap();
b69ab311207 const toCommitRev = (rev: FileRev) => fileIdxRevToCommitRev(fileIdx, rev);
b69ab311208 // Convert file revs to commit revs.
b69ab311209 fileDepMap.forEach((valueFileRevs, keyFileRev) => {
b69ab311210 const keyCommitRev = toCommitRev(keyFileRev);
b69ab311211 if (keyCommitRev >= 0) {
b69ab311212 const set = nullthrows(depMap.get(keyCommitRev));
b69ab311213 valueFileRevs.forEach(fileRev => {
b69ab311214 const rev = toCommitRev(fileRev);
b69ab311215 if (rev >= 0) {
b69ab311216 set.add(rev);
b69ab311217 }
b69ab311218 });
b69ab311219 }
b69ab311220 });
b69ab311221 });
b69ab311222
b69ab311223 // Besides, file deletion / addition / renames also introduce dependencies.
b69ab311224 state.stack.forEach(commit => {
b69ab311225 const set = nullthrows(depMap.get(commit.rev));
b69ab311226 commit.files.forEach((file, path) => {
b69ab311227 const [prevRev, prevPath, prevFile] = state.parentFile(commit.rev, path, true);
b69ab311228 if (prevRev >= 0 && (isAbsent(prevFile) !== isAbsent(file) || prevPath !== path)) {
b69ab311229 set.add(prevRev);
b69ab311230 }
b69ab311231 });
b69ab311232 });
b69ab311233
b69ab311234 return depMap;
b69ab311235 }
b69ab311236
b69ab311237 /** Return the single parent rev, or null. */
b69ab311238 singleParentRev(rev: CommitRev): CommitRev | null {
b69ab311239 const commit = this.stack.get(rev);
b69ab311240 const parents = commit?.parents;
b69ab311241 if (parents != null) {
b69ab311242 const parentRev = parents?.first();
b69ab311243 if (parentRev != null && parents.size === 1) {
b69ab311244 return parentRev;
b69ab311245 }
b69ab311246 }
b69ab311247 return null;
b69ab311248 }
b69ab311249
b69ab311250 /**
b69ab311251 * Test if the commit can be folded with its parent.
b69ab311252 */
b69ab311253 canFoldDown = cachedMethod(this.canFoldDownImpl, {cache: canFoldDownCache});
b69ab311254 private canFoldDownImpl(rev: CommitRev): boolean {
b69ab311255 if (rev <= 0) {
b69ab311256 return false;
b69ab311257 }
b69ab311258 const commit = this.stack.get(rev);
b69ab311259 if (commit == null) {
b69ab311260 return false;
b69ab311261 }
b69ab311262 const parentRev = this.singleParentRev(rev);
b69ab311263 if (parentRev == null) {
b69ab311264 return false;
b69ab311265 }
b69ab311266 const parent = nullthrows(this.stack.get(parentRev));
b69ab311267 if (commit.immutableKind !== 'none' || parent.immutableKind !== 'none') {
b69ab311268 return false;
b69ab311269 }
b69ab311270 // This is a bit conservative. But we're not doing complex content check for now.
b69ab311271 const childCount = this.stack.count(c => c.parents.includes(parentRev));
b69ab311272 if (childCount > 1) {
b69ab311273 return false;
b69ab311274 }
b69ab311275 return true;
b69ab311276 }
b69ab311277
b69ab311278 /**
b69ab311279 * Drop the given `rev`.
b69ab311280 * The callsite should take care of `files` updates.
b69ab311281 */
b69ab311282 rewriteStackDroppingRev(rev: CommitRev): CommitStackState {
b69ab311283 const revMapFunc = (r: CommitRev) => (r < rev ? r : prev(r));
b69ab311284 const newStack = this.stack
b69ab311285 .filter(c => c.rev !== rev)
b69ab311286 .map(c => rewriteCommitRevs(c, revMapFunc));
b69ab311287 // Recalculate file stacks.
b69ab311288 return this.set('stack', newStack).buildFileStacks();
b69ab311289 }
b69ab311290
b69ab311291 /**
b69ab311292 * Fold the commit with its parent.
b69ab311293 * This should only be called when `canFoldDown(rev)` returned `true`.
b69ab311294 */
b69ab311295 foldDown(rev: CommitRev) {
b69ab311296 const commit = nullthrows(this.stack.get(rev));
b69ab311297 const parentRev = nullthrows(this.singleParentRev(rev));
b69ab311298 const parent = nullthrows(this.stack.get(parentRev));
b69ab311299 let newParentFiles = parent.files;
b69ab311300 const newFiles = commit.files.map((origFile, path) => {
b69ab311301 // Fold copyFrom. `-` means "no change".
b69ab311302 //
b69ab311303 // | grand | direct | | |
b69ab311304 // | parent | parent | rev | folded (copyFrom) |
b69ab311305 // +--------------------------------------------+
b69ab311306 // | A | A->B | B->C | A->C (parent) |
b69ab311307 // | A | A->B | B | A->B (parent) |
b69ab311308 // | A | A->B | - | A->B (parent) |
b69ab311309 // | A | A | A->C | A->C (rev) |
b69ab311310 // | A | - | A->C | A->C (rev) |
b69ab311311 // | - | B | B->C | C (drop) |
b69ab311312 let file = origFile;
b69ab311313 const optionalParentFile = newParentFiles.get(file.copyFrom ?? path);
b69ab311314 const copyFrom = optionalParentFile?.copyFrom ?? file.copyFrom;
b69ab311315 if (copyFrom != null && isAbsent(this.parentFile(parentRev, file.copyFrom ?? path)[2])) {
b69ab311316 // "copyFrom" is no longer valid (not existed in grand parent). Drop it.
b69ab311317 file = file.set('copyFrom', undefined);
b69ab311318 } else {
b69ab311319 file = file.set('copyFrom', copyFrom);
b69ab311320 }
b69ab311321 if (this.isEqualFile(this.parentFile(parentRev, path, false /* [1] */)[2], file)) {
b69ab311322 // The file changes cancel out. Remove it.
b69ab311323 // [1]: we need to disable following renames when comparing files for cancel-out check.
b69ab311324 newParentFiles = newParentFiles.delete(path);
b69ab311325 } else {
b69ab311326 // Fold the change of this file.
b69ab311327 newParentFiles = newParentFiles.set(path, file);
b69ab311328 }
b69ab311329 return file;
b69ab311330 });
b69ab311331
b69ab311332 // Fold other properties to parent.
b69ab311333 let newParentText = parent.text;
b69ab311334 if (isMeaningfulText(commit.text)) {
b69ab311335 const schema = readAtom(commitMessageFieldsSchema);
b69ab311336 const parentTitle = firstLine(parent.text);
b69ab311337 const parentFields = parseCommitMessageFields(
b69ab311338 schema,
b69ab311339 parentTitle,
b69ab311340 parent.text.slice(parentTitle.length),
b69ab311341 );
b69ab311342 const commitTitle = firstLine(commit.text);
b69ab311343 const commitFields = parseCommitMessageFields(
b69ab311344 schema,
b69ab311345 commitTitle,
b69ab311346 commit.text.slice(commitTitle.length),
b69ab311347 );
b69ab311348 const merged = mergeCommitMessageFields(schema, parentFields, commitFields);
b69ab311349 newParentText = commitMessageFieldsToString(schema, merged);
b69ab311350 }
b69ab311351
b69ab311352 const newParent = parent.merge({
b69ab311353 text: newParentText,
b69ab311354 date: commit.date,
b69ab311355 originalNodes: parent.originalNodes.merge(commit.originalNodes),
b69ab311356 files: newParentFiles,
b69ab311357 });
b69ab311358 const newCommit = commit.set('files', newFiles);
b69ab311359 const newStack = this.stack.withMutations(mutStack => {
b69ab311360 mutStack.set(parentRev, newParent).set(rev, newCommit);
b69ab311361 });
b69ab311362
b69ab311363 return this.set('stack', newStack).rewriteStackDroppingRev(rev);
b69ab311364 }
b69ab311365
b69ab311366 /**
b69ab311367 * Test if the commit can be dropped. That is, none of its descendants depend on it.
b69ab311368 */
b69ab311369 canDrop = cachedMethod(this.canDropImpl, {cache: canDropCache});
b69ab311370 private canDropImpl(rev: CommitRev): boolean {
b69ab311371 if (rev < 0 || this.stack.get(rev)?.immutableKind !== 'none') {
b69ab311372 return false;
b69ab311373 }
b69ab311374 const depMap = this.calculateDepMap();
b69ab311375 for (const [currentRev, dependentRevs] of depMap.entries()) {
b69ab311376 if (dependentRevs.has(rev) && generatorContains(this.log(currentRev), rev)) {
b69ab311377 return false;
b69ab311378 }
b69ab311379 }
b69ab311380 return true;
b69ab311381 }
b69ab311382
b69ab311383 /**
b69ab311384 * Drop a commit. Changes made by the commit will be removed in its
b69ab311385 * descendants.
b69ab311386 *
b69ab311387 * This should only be called when `canDrop(rev)` returned `true`.
b69ab311388 */
b69ab311389 drop(rev: CommitRev): CommitStackState {
b69ab311390 let state = this.useFileStack().inner;
b69ab311391 const commit = nullthrows(state.stack.get(rev));
b69ab311392 commit.files.forEach((file, path) => {
b69ab311393 const fileIdxRev: FileIdx | undefined = state.commitToFile.get(CommitIdx({rev, path}));
b69ab311394 if (fileIdxRev != null) {
b69ab311395 const {fileIdx, fileRev} = fileIdxRev;
b69ab311396 const fileStack = nullthrows(state.fileStacks.get(fileIdx));
b69ab311397 // Drop the rev by remapping it to an unused rev.
b69ab311398 const unusedFileRev = fileStack.source.revLength;
b69ab311399 const newFileStack = fileStack.remapRevs(new Map([[fileRev, unusedFileRev]]));
b69ab311400 state = state.setIn(['fileStacks', fileIdx], newFileStack);
b69ab311401 }
b69ab311402 });
b69ab311403
b69ab311404 return new CommitStackState(undefined, state).rewriteStackDroppingRev(rev);
b69ab311405 }
b69ab311406
b69ab311407 /**
b69ab311408 * Insert an empty commit at `rev`.
b69ab311409 * Cannot insert to an empty stack.
b69ab311410 */
b69ab311411 insertEmpty(rev: CommitRev, message: string, splitFromRev?: CommitRev): CommitStackState {
b69ab311412 assert(rev <= this.stack.size && rev >= 0, 'rev out of range');
b69ab311413 const state = this.useFileContent();
b69ab311414 let newStack;
b69ab311415 const newKey = this.nextKey('insert');
b69ab311416 const originalNodes = splitFromRev == null ? undefined : state.get(splitFromRev)?.originalNodes;
b69ab311417 if (rev === this.stack.size) {
b69ab311418 const top = this.stack.last();
b69ab311419 assert(top != null, 'stack cannot be empty');
b69ab311420 newStack = this.stack.push(
b69ab311421 CommitState({
b69ab311422 rev,
b69ab311423 parents: List(rev === 0 ? [] : [prev(rev)]),
b69ab311424 text: message,
b69ab311425 key: newKey,
b69ab311426 author: top.author,
b69ab311427 date: top.date,
b69ab311428 originalNodes,
b69ab311429 }),
b69ab311430 );
b69ab311431 } else {
b69ab311432 const revMapFunc = (r: CommitRev) => (r >= rev ? next(r) : r);
b69ab311433 const origParents = nullthrows(state.stack.get(rev)).parents;
b69ab311434 newStack = state.stack
b69ab311435 .map(c => rewriteCommitRevs(c, revMapFunc))
b69ab311436 .flatMap(c => {
b69ab311437 if (c.rev == rev + 1) {
b69ab311438 return Seq([
b69ab311439 CommitState({
b69ab311440 rev,
b69ab311441 parents: origParents,
b69ab311442 text: message,
b69ab311443 key: newKey,
b69ab311444 author: c.author,
b69ab311445 date: c.date,
b69ab311446 originalNodes,
b69ab311447 }),
b69ab311448 c.set('parents', List([rev])),
b69ab311449 ]);
b69ab311450 } else {
b69ab311451 return Seq([c]);
b69ab311452 }
b69ab311453 });
b69ab311454 }
b69ab311455 return this.set('stack', newStack).buildFileStacks();
b69ab311456 }
b69ab311457
b69ab311458 /**
b69ab311459 * Update commit message.
b69ab311460 */
b69ab311461 editCommitMessage(rev: CommitRev, message: string): CommitStackState {
b69ab311462 assert(rev <= this.stack.size && rev >= 0, 'rev out of range');
b69ab311463 const newStack = this.stack.setIn([rev, 'text'], message);
b69ab311464 return this.set('stack', newStack);
b69ab311465 }
b69ab311466
b69ab311467 /**
b69ab311468 * Find a unique "key" not yet used by the commit stack.
b69ab311469 */
b69ab311470 nextKey(prefix: string): string {
b69ab311471 const usedKeys = ImSet(this.stack.map(c => c.key));
b69ab311472 for (let i = 0; ; i++) {
b69ab311473 const key = `${prefix}-${i}`;
b69ab311474 if (usedKeys.has(key)) {
b69ab311475 continue;
b69ab311476 }
b69ab311477 return key;
b69ab311478 }
b69ab311479 }
b69ab311480
b69ab311481 /**
b69ab311482 * Check if reorder is conflict-free.
b69ab311483 *
b69ab311484 * `order` defines the new order as a "from rev" list.
b69ab311485 * For example, when `this.revs()` is `[0, 1, 2, 3]` and `order` is
b69ab311486 * `[0, 2, 3, 1]`, it means moving the second (rev 1) commit to the
b69ab311487 * stack top.
b69ab311488 *
b69ab311489 * Reordering in a non-linear stack is not supported and will return
b69ab311490 * `false`. This is because it's tricky to describe the desired
b69ab311491 * new parent relationships with just `order`.
b69ab311492 *
b69ab311493 * If `order` is `this.revs()` then no reorder is done.
b69ab311494 */
b69ab311495 canReorder(order: CommitRev[]): boolean {
b69ab311496 const state = this.useFileStack();
b69ab311497 if (!state.isStackLinear()) {
b69ab311498 return false;
b69ab311499 }
b69ab311500 if (
b69ab311501 !deepEqual(
b69ab311502 [...order].sort((a, b) => a - b),
b69ab311503 state.revs(),
b69ab311504 )
b69ab311505 ) {
b69ab311506 return false;
b69ab311507 }
b69ab311508
b69ab311509 // "hash" immutable commits cannot be moved.
b69ab311510 if (state.stack.some((commit, rev) => commit.immutableKind === 'hash' && order[rev] !== rev)) {
b69ab311511 return false;
b69ab311512 }
b69ab311513
b69ab311514 const map = new Map<CommitRev, CommitRev>(
b69ab311515 order.map((fromRev, toRev) => [fromRev as CommitRev, toRev as CommitRev]),
b69ab311516 );
b69ab311517 // Check dependencies.
b69ab311518 const depMap = state.calculateDepMap();
b69ab311519 for (const [rev, depRevs] of depMap) {
b69ab311520 const newRev = map.get(rev);
b69ab311521 if (newRev == null) {
b69ab311522 return false;
b69ab311523 }
b69ab311524 for (const depRev of depRevs) {
b69ab311525 const newDepRev = map.get(depRev);
b69ab311526 if (newDepRev == null) {
b69ab311527 return false;
b69ab311528 }
b69ab311529 if (!generatorContains(state.log(newRev), newDepRev)) {
b69ab311530 return false;
b69ab311531 }
b69ab311532 }
b69ab311533 }
b69ab311534 // Passed checks.
b69ab311535 return true;
b69ab311536 }
b69ab311537
b69ab311538 canMoveDown = cachedMethod(this.canMoveDownImpl, {cache: canMoveDownCache});
b69ab311539 private canMoveDownImpl(rev: CommitRev): boolean {
b69ab311540 return rev > 0 && this.canMoveUp(prev(rev));
b69ab311541 }
b69ab311542
b69ab311543 canMoveUp = cachedMethod(this.canMoveUpImpl, {cache: canMoveUpCache});
b69ab311544 private canMoveUpImpl(rev: CommitRev): boolean {
b69ab311545 return this.canReorder(reorderedRevs(this, rev));
b69ab311546 }
b69ab311547
b69ab311548 /**
b69ab311549 * Reorder stack. Similar to running `histedit`, followed by reordering
b69ab311550 * commits.
b69ab311551 *
b69ab311552 * See `canReorder` for the meaning of `order`.
b69ab311553 * This should only be called when `canReorder(order)` returned `true`.
b69ab311554 */
b69ab311555 reorder(order: CommitRev[]): CommitStackState {
b69ab311556 const commitRevMap = new Map<CommitRev, CommitRev>(
b69ab311557 order.map((fromRev, toRev) => [fromRev, toRev as CommitRev]),
b69ab311558 );
b69ab311559
b69ab311560 // Reorder file contents. This is somewhat tricky involving multiple
b69ab311561 // mappings. Here is an example:
b69ab311562 //
b69ab311563 // Stack: A-B-C-D. Original file contents: [11, 112, 0112, 01312].
b69ab311564 // Reorder to: A-D-B-C. Expected result: [11, 131, 1312, 01312].
b69ab311565 //
b69ab311566 // First, we figure out the file stack, and reorder it. The file stack
b69ab311567 // now has the content [11 (A), 131 (B), 1312 (C), 01312 (D)], but the
b69ab311568 // commit stack is still in the A-B-C-D order and refers to the file stack
b69ab311569 // using **fileRev**s. If we blindly reorder the commit stack to A-D-B-C,
b69ab311570 // the resulting files would be [11 (A), 01312 (D), 131 (B), 1312 (C)].
b69ab311571 //
b69ab311572 // To make it work properly, we apply a reverse mapping (A-D-B-C =>
b69ab311573 // A-B-C-D) to the file stack before reordering commits, changing
b69ab311574 // [11 (A), 131 (D), 1312 (B), 01312 (C)] to [11 (A), 1312 (B), 01312 (C),
b69ab311575 // 131 (D)]. So after the commit remapping it produces the desired
b69ab311576 // output.
b69ab311577 let state = this.useFileStack();
b69ab311578 const newFileStacks = state.fileStacks.map((origFileStack, fileIdx) => {
b69ab311579 let fileStack: FileStackState = origFileStack;
b69ab311580
b69ab311581 // file revs => commit revs => mapped commit revs => mapped file revs
b69ab311582 const fileRevs = fileStack.revs();
b69ab311583 const commitRevPaths: CommitIdx[] = fileRevs.map(fileRev =>
b69ab311584 nullthrows(state.fileToCommit.get(FileIdx({fileIdx, fileRev}))),
b69ab311585 );
b69ab311586 const commitRevs: CommitRev[] = commitRevPaths.map(({rev}) => rev);
b69ab311587 const mappedCommitRevs: CommitRev[] = commitRevs.map(rev => commitRevMap.get(rev) ?? rev);
b69ab311588 // commitRevs and mappedCommitRevs might not overlap, although they
b69ab311589 // have the same length (fileRevs.length). Turn them into compact
b69ab311590 // sequence to reason about.
b69ab311591 const fromRevs: FileRev[] = compactSequence(commitRevs);
b69ab311592 const toRevs: FileRev[] = compactSequence(mappedCommitRevs);
b69ab311593 if (deepEqual(fromRevs, toRevs)) {
b69ab311594 return fileStack;
b69ab311595 }
b69ab311596 // Mapping: zip(original revs, mapped file revs)
b69ab311597 const fileRevMap = new Map<FileRev, FileRev>(zip(fromRevs, toRevs));
b69ab311598 fileStack = fileStack.remapRevs(fileRevMap);
b69ab311599 // Apply the reverse mapping. See the above comment for why this is necessary.
b69ab311600 return new FileStackState(fileRevs.map(fileRev => fileStack.getRev(toRevs[fileRev])));
b69ab311601 });
b69ab311602 state = state.set('fileStacks', newFileStacks);
b69ab311603
b69ab311604 // Update state.stack.
b69ab311605 const newStack = state.stack.map((_commit, rev) => {
b69ab311606 const commit = nullthrows(state.stack.get(order[rev]));
b69ab311607 return commit.merge({
b69ab311608 parents: List(rev > 0 ? [prev(rev as CommitRev)] : []),
b69ab311609 rev: rev as CommitRev,
b69ab311610 });
b69ab311611 });
b69ab311612 state = state.set('stack', newStack);
b69ab311613
b69ab311614 return state.buildFileStacks();
b69ab311615 }
b69ab311616
b69ab311617 /** Replace a file stack. Throws if the new stack has a different length. */
b69ab311618 setFileStack(fileIdx: number, stack: FileStackState): CommitStackState {
b69ab311619 return this.setFileStackInternal(fileIdx, stack, (oldStack, newStack) => {
b69ab311620 assert(oldStack.revLength === newStack.revLength, 'fileStack length mismatch');
b69ab311621 });
b69ab311622 }
b69ab311623
b69ab311624 /** Internal use: replace a file stack. */
b69ab311625 private setFileStackInternal(
b69ab311626 fileIdx: number,
b69ab311627 stack: FileStackState,
b69ab311628 check?: (oldStack: FileStackState, newStack: FileStackState) => void,
b69ab311629 ): CommitStackState {
b69ab311630 const oldStack = this.fileStacks.get(fileIdx);
b69ab311631 assert(oldStack != null, 'fileIdx out of range');
b69ab311632 check?.(oldStack, stack);
b69ab311633 const newInner = this.inner.setIn(['fileStacks', fileIdx], stack);
b69ab311634 return new CommitStackState(undefined, newInner);
b69ab311635 }
b69ab311636
b69ab311637 /**
b69ab311638 * Extract part of the commit stack as a new linear stack.
b69ab311639 *
b69ab311640 * The new stack is "dense" in a way that each commit's "files"
b69ab311641 * include all files every referred by the stack, even if the
b69ab311642 * file is not modified.
b69ab311643 *
b69ab311644 * The new stack:
b69ab311645 * - Does not have "originalStack".
b69ab311646 * - "Dense". Therefore file revs (in fileStacks) map to all
b69ab311647 * commits.
b69ab311648 * - Preserves the rename information, but does not follow renames
b69ab311649 * when building the file stacks.
b69ab311650 * - Preserves non-utf8 files, but does not build into the file
b69ab311651 * stacks, which means their content cannot be edited, but might
b69ab311652 * still be moved around.
b69ab311653 *
b69ab311654 * It is for the interactive split use-case.
b69ab311655 */
b69ab311656 denseSubStack(revs: List<CommitRev>): CommitStackState {
b69ab311657 const commits = revs.map(rev => this.stack.get(rev)).filter(Boolean) as List<CommitState>;
b69ab311658 const bottomFiles = new Map<RepoPath, FileState>();
b69ab311659 const followRenames = false;
b69ab311660
b69ab311661 // Use this.parentFile to populate bottomFiles.
b69ab311662 commits.forEach(commit => {
b69ab311663 const startRev = commit.rev;
b69ab311664 commit.files.forEach((file, startPath) => {
b69ab311665 ([startPath].filter(Boolean) as [string]).forEach(path => {
b69ab311666 if (!bottomFiles.has(path)) {
b69ab311667 const [, , file] = this.parentFile(startRev, path, false);
b69ab311668 bottomFiles.set(path, file);
b69ab311669 }
b69ab311670 if (file.copyFrom != null) {
b69ab311671 const [, fromPath, fromFile] = this.parentFile(startRev, path, true);
b69ab311672 bottomFiles.set(fromPath, fromFile);
b69ab311673 }
b69ab311674 });
b69ab311675 });
b69ab311676 });
b69ab311677
b69ab311678 // Modify stack:
b69ab311679 // - Re-assign "rev"s (including "parents").
b69ab311680 // - Assign file contents so files are considered changed in every commit.
b69ab311681 const currentFiles = new Map(bottomFiles);
b69ab311682 const stack: List<CommitState> = commits.map((commit, i) => {
b69ab311683 const newFiles = commit.files.withMutations(mut => {
b69ab311684 let files = mut;
b69ab311685 // Add unchanged files to force treating files as "modified".
b69ab311686 currentFiles.forEach((file, path) => {
b69ab311687 const inCommitFile = files.get(path);
b69ab311688 if (inCommitFile == undefined) {
b69ab311689 // Update files so all files are considered changed and got a file rev assigned.
b69ab311690 files = files.set(path, file ?? ABSENT_FILE);
b69ab311691 } else {
b69ab311692 // Update currentFiles so it can be used by the next commit.
b69ab311693 // Avoid repeating "copyFrom".
b69ab311694 currentFiles.set(path, inCommitFile.remove('copyFrom'));
b69ab311695 }
b69ab311696 });
b69ab311697 return files;
b69ab311698 });
b69ab311699 const parents = i === 0 ? List<CommitRev>() : List([prev(i as CommitRev)]);
b69ab311700 return commit.merge({rev: i as CommitRev, files: newFiles, parents});
b69ab311701 });
b69ab311702
b69ab311703 const record = CommitStackRecord({
b69ab311704 stack,
b69ab311705 bottomFiles,
b69ab311706 });
b69ab311707 const newStack = new CommitStackState(undefined, record);
b69ab311708 return newStack.buildFileStacks({followRenames}).useFileStack();
b69ab311709 }
b69ab311710
b69ab311711 /**
b69ab311712 * Replace the `startRev` (inclusive) to `endRev` (exclusive) sub stack
b69ab311713 * with commits from the `subStack`.
b69ab311714 *
b69ab311715 * Unmodified changes will be dropped. Top commits with empty changes are
b69ab311716 * dropped. This turns a "dense" back to a non-"dense" one.
b69ab311717 *
b69ab311718 * Intended for interactive split use-case.
b69ab311719 */
b69ab311720 applySubStack(
b69ab311721 startRev: CommitRev,
b69ab311722 endRev: CommitRev,
b69ab311723 subStack: CommitStackState,
b69ab311724 ): CommitStackState {
b69ab311725 assert(
b69ab311726 startRev >= 0 && endRev <= this.stack.size && startRev < endRev,
b69ab311727 'startRev or endRev out of range',
b69ab311728 );
b69ab311729
b69ab311730 const contentSubStack = subStack.useFileContent();
b69ab311731 const state = this.useFileContent();
b69ab311732
b69ab311733 // Used to detect "unchanged" files in subStack.
b69ab311734 const afterFileMap = new Map(
b69ab311735 [...state.bottomFiles.entries()].map(([path, file]) => [path, file]),
b69ab311736 );
b69ab311737
b69ab311738 // Used to check the original "final" content of files.
b69ab311739 const beforeFileMap = new Map(afterFileMap);
b69ab311740
b69ab311741 const updateFileMap = (commit: CommitState, map: Map<string, FileState>) =>
b69ab311742 commit.files.forEach((file, path) => map.set(path, file));
b69ab311743
b69ab311744 // Pick an unused key.
b69ab311745 const usedKeys = new Set(
b69ab311746 state.stack
b69ab311747 .filter(c => c.rev < startRev || c.rev >= endRev)
b69ab311748 .map(c => c.key)
b69ab311749 .toArray(),
b69ab311750 );
b69ab311751 const pickKey = (c: CommitState): CommitState => {
b69ab311752 if (usedKeys.has(c.key)) {
b69ab311753 for (let i = 0; ; ++i) {
b69ab311754 const key = `${c.key}-${i}`;
b69ab311755 if (!usedKeys.has(key)) {
b69ab311756 usedKeys.add(c.key);
b69ab311757 return c.set('key', key);
b69ab311758 }
b69ab311759 }
b69ab311760 } else {
b69ab311761 usedKeys.add(c.key);
b69ab311762 return c;
b69ab311763 }
b69ab311764 };
b69ab311765
b69ab311766 // Process commits in a "dense" stack.
b69ab311767 // - Update afterFileMap.
b69ab311768 // - Drop unchanged files.
b69ab311769 // - Drop the "absent" flag from files if they are not empty.
b69ab311770 // - Pick a unique key.
b69ab311771 // - Add "parent" for the first commit.
b69ab311772 // - Adjust "revs".
b69ab311773 const processDenseCommit = (c: CommitState): CommitState => {
b69ab311774 const newFiles = c.files.flatMap<RepoPath, FileState>((currentFile, path) => {
b69ab311775 let file: FileState = currentFile;
b69ab311776 const oldFile = afterFileMap.get(path);
b69ab311777 // Drop "absent" flag (and reuse the old flag).
b69ab311778 if (
b69ab311779 file.flags?.includes(ABSENT_FLAG) &&
b69ab311780 typeof file.data === 'string' &&
b69ab311781 file.data.length > 0
b69ab311782 ) {
b69ab311783 let oldFlag = oldFile?.flags;
b69ab311784 if (oldFlag === ABSENT_FLAG) {
b69ab311785 oldFlag = undefined;
b69ab311786 }
b69ab311787 if (oldFlag == null) {
b69ab311788 file = file.remove('flags');
b69ab311789 } else {
b69ab311790 file = file.set('flags', oldFlag);
b69ab311791 }
b69ab311792 }
b69ab311793 // Drop unchanged files.
b69ab311794 const keep = oldFile == null || !isContentSame(oldFile, file);
b69ab311795 // Update afterFileMap.
b69ab311796 if (keep) {
b69ab311797 afterFileMap.set(path, file);
b69ab311798 }
b69ab311799 return Seq(keep ? [[path, file]] : []);
b69ab311800 });
b69ab311801 const isFirst = c.rev === 0;
b69ab311802 let commit = rewriteCommitRevs(pickKey(c), r => (r + startRev) as CommitRev).set(
b69ab311803 'files',
b69ab311804 newFiles,
b69ab311805 );
b69ab311806 if (isFirst && startRev > 0) {
b69ab311807 commit = commit.set('parents', List([prev(startRev)]));
b69ab311808 }
b69ab311809 return commit;
b69ab311810 };
b69ab311811
b69ab311812 // |<--- to delete --->|
b69ab311813 // Before: ... |startRev ... endRev| ...
b69ab311814 // New: ... |filter(substack) | ...
b69ab311815 // filter: remove empty commits
b69ab311816 let newSubStackSize = 0;
b69ab311817 const newStack = state.stack.flatMap(c => {
b69ab311818 updateFileMap(c, beforeFileMap);
b69ab311819 if (c.rev < startRev) {
b69ab311820 updateFileMap(c, afterFileMap);
b69ab311821 return Seq([c]);
b69ab311822 } else if (c.rev === startRev) {
b69ab311823 // dropUnchangedFiles updates afterFileMap.
b69ab311824 let commits = contentSubStack.stack.map(c => processDenseCommit(c));
b69ab311825 // Drop empty commits at the end. Adjust offset.
b69ab311826 while (commits.last()?.files?.isEmpty()) {
b69ab311827 commits = commits.pop();
b69ab311828 }
b69ab311829 newSubStackSize = commits.size;
b69ab311830 return commits;
b69ab311831 } else if (c.rev > startRev && c.rev < endRev) {
b69ab311832 return Seq([]);
b69ab311833 } else {
b69ab311834 let commit = c;
b69ab311835 assert(c.rev >= endRev, 'bug: c.rev < endRev should be handled above');
b69ab311836 if (c.rev === endRev) {
b69ab311837 // This commit should have the same exact content as before, not just the
b69ab311838 // modified files, but also the unmodified ones.
b69ab311839 // We check all files ever changed by the stack between "before" and "after",
b69ab311840 // and bring their content back to "before" in this commit.
b69ab311841 beforeFileMap.forEach((beforeFile, path) => {
b69ab311842 if (commit.files.has(path)) {
b69ab311843 return;
b69ab311844 }
b69ab311845 const afterFile = afterFileMap.get(path);
b69ab311846 if (afterFile == null || !isContentSame(beforeFile, afterFile)) {
b69ab311847 commit = commit.setIn(['files', path], beforeFile);
b69ab311848 }
b69ab311849 });
b69ab311850 // Delete file added by the subStack that do not exist before.
b69ab311851 afterFileMap.forEach((_, path) => {
b69ab311852 if (!beforeFileMap.has(path)) {
b69ab311853 commit = commit.setIn(['files', path], ABSENT_FILE);
b69ab311854 }
b69ab311855 });
b69ab311856 }
b69ab311857 const offset = newSubStackSize - (endRev - startRev);
b69ab311858 return Seq([
b69ab311859 rewriteCommitRevs(
b69ab311860 commit,
b69ab311861 r => ((r >= startRev && r < endRev ? endRev - 1 : r) + offset) as CommitRev,
b69ab311862 ),
b69ab311863 ]);
b69ab311864 }
b69ab311865 });
b69ab311866
b69ab311867 // This function might be frequnetly called during interacitve split.
b69ab311868 // Do not build file stacks (potentially slow) now.
b69ab311869 return state.set('stack', newStack);
b69ab311870 }
b69ab311871
b69ab311872 /** Test if a path at the given rev is a renamed (not copy). */
b69ab311873 isRename(rev: CommitRev, path: RepoPath): boolean {
b69ab311874 const commit = this.get(rev);
b69ab311875 if (commit == null) {
b69ab311876 return false;
b69ab311877 }
b69ab311878 return isRename(commit, path);
b69ab311879 }
b69ab311880
b69ab311881 /**
b69ab311882 * If the given file has a metadata change, return the old and new metadata.
b69ab311883 * Otherwise, return undefined.
b69ab311884 */
b69ab311885 changedFileMetadata(
b69ab311886 rev: CommitRev,
b69ab311887 path: RepoPath,
b69ab311888 followRenames = false,
b69ab311889 ): [FileMetadata, FileMetadata] | undefined {
b69ab311890 const file = this.getFile(rev, path);
b69ab311891 const parentFile = this.parentFile(rev, path, followRenames)[2];
b69ab311892 const fileMeta = toMetadata(file);
b69ab311893 // Only report "changed" if copyFrom is newly set.
b69ab311894 const parentMeta = toMetadata(parentFile).remove('copyFrom');
b69ab311895 return fileMeta.equals(parentMeta) ? undefined : [parentMeta, fileMeta];
b69ab311896 }
b69ab311897}
b69ab311898
b69ab311899const canDropCache = new LRU(1000);
b69ab311900const calculateDepMapCache = new LRU(1000);
b69ab311901const canFoldDownCache = new LRU(1000);
b69ab311902const canMoveUpCache = new LRU(1000);
b69ab311903const canMoveDownCache = new LRU(1000);
b69ab311904
b69ab311905function getBottomFilesFromExportStack(stack: Readonly<ExportStack>): Map<RepoPath, FileState> {
b69ab311906 // bottomFiles requires that the stack only has one root.
b69ab311907 checkStackSingleRoot(stack);
b69ab311908
b69ab311909 // Calculate bottomFiles.
b69ab311910 const bottomFiles: Map<RepoPath, FileState> = new Map();
b69ab311911 stack.forEach(commit => {
b69ab311912 for (const [path, file] of Object.entries(commit.relevantFiles ?? {})) {
b69ab311913 if (!bottomFiles.has(path)) {
b69ab311914 bottomFiles.set(path, convertExportFileToFileState(file));
b69ab311915 }
b69ab311916 }
b69ab311917
b69ab311918 // Files not yet existed in `bottomFiles` means they are added (in root commits)
b69ab311919 // mark them as "missing" in the stack bottom.
b69ab311920 for (const path of Object.keys(commit.files ?? {})) {
b69ab311921 if (!bottomFiles.has(path)) {
b69ab311922 bottomFiles.set(path, ABSENT_FILE);
b69ab311923 }
b69ab311924 }
b69ab311925 });
b69ab311926
b69ab311927 return bottomFiles;
b69ab311928}
b69ab311929
b69ab311930function convertExportFileToFileState(file: ExportFile | null): FileState {
b69ab311931 if (file == null) {
b69ab311932 return ABSENT_FILE;
b69ab311933 }
b69ab311934 return FileState({
b69ab311935 data:
b69ab311936 file.data != null
b69ab311937 ? file.data
b69ab311938 : file.dataBase85
b69ab311939 ? Base85({dataBase85: file.dataBase85})
b69ab311940 : DataRef(nullthrows(file.dataRef)),
b69ab311941 copyFrom: file.copyFrom,
b69ab311942 flags: file.flags,
b69ab311943 });
b69ab311944}
b69ab311945
b69ab311946function getCommitStatesFromExportStack(stack: Readonly<ExportStack>): List<CommitState> {
b69ab311947 checkStackParents(stack);
b69ab311948
b69ab311949 // Prepare nodeToRev conversion.
b69ab311950 const revs: CommitRev[] = [...stack.keys()] as CommitRev[];
b69ab311951 const nodeToRevMap: Map<Hash, CommitRev> = new Map(revs.map(rev => [stack[rev].node, rev]));
b69ab311952 const nodeToRev = (node: Hash): CommitRev => {
b69ab311953 const rev = nodeToRevMap.get(node);
b69ab311954 if (rev == null) {
b69ab311955 throw new Error(
b69ab311956 `Rev ${rev} should be known ${JSON.stringify(nodeToRevMap)} (bug in debugexportstack?)`,
b69ab311957 );
b69ab311958 }
b69ab311959 return rev;
b69ab311960 };
b69ab311961
b69ab311962 // Calculate requested stack.
b69ab311963 const commitStates = stack.map(commit =>
b69ab311964 CommitState({
b69ab311965 originalNodes: ImSet([commit.node]),
b69ab311966 rev: nodeToRev(commit.node),
b69ab311967 key: commit.node,
b69ab311968 author: commit.author,
b69ab311969 date: DateTuple({unix: commit.date[0], tz: commit.date[1]}),
b69ab311970 text: commit.text,
b69ab311971 // Treat commits that are not requested explicitly as immutable too.
b69ab311972 immutableKind: commit.immutable || !commit.requested ? 'hash' : 'none',
b69ab311973 parents: List((commit.parents ?? []).map(p => nodeToRev(p))),
b69ab311974 files: ImMap<RepoPath, FileState>(
b69ab311975 Object.entries(commit.files ?? {}).map(([path, file]) => [
b69ab311976 path,
b69ab311977 convertExportFileToFileState(file),
b69ab311978 ]),
b69ab311979 ),
b69ab311980 }),
b69ab311981 );
b69ab311982
b69ab311983 return List(commitStates);
b69ab311984}
b69ab311985
b69ab311986/** Check that there is only one root in the stack. */
b69ab311987function checkStackSingleRoot(stack: Readonly<ExportStack>) {
b69ab311988 const rootNodes = stack.filter(commit => (commit.parents ?? []).length === 0);
b69ab311989 if (rootNodes.length > 1) {
b69ab311990 throw new Error(
b69ab311991 `Multiple roots ${JSON.stringify(rootNodes.map(c => c.node))} is not supported`,
b69ab311992 );
b69ab311993 }
b69ab311994}
b69ab311995
b69ab311996/**
b69ab311997 * Check the exported stack and throws if it breaks assumptions.
b69ab311998 * - No duplicated commits.
b69ab311999 * - "parents" refer to other commits in the stack.
b69ab312000 */
b69ab312001function checkStackParents(stack: Readonly<ExportStack>) {
b69ab312002 const knownNodes = new Set();
b69ab312003 stack.forEach(commit => {
b69ab312004 const parents = commit.parents ?? [];
b69ab312005 if (parents.length > 0) {
b69ab312006 if (!commit.requested) {
b69ab312007 throw new Error(
b69ab312008 `Requested commit ${commit.node} should not have parents ${JSON.stringify(
b69ab312009 parents,
b69ab312010 )} (bug in debugexportstack?)`,
b69ab312011 );
b69ab312012 }
b69ab312013 parents.forEach(parentNode => {
b69ab312014 if (!knownNodes.has(parentNode)) {
b69ab312015 throw new Error(`Parent commit ${parentNode} is not exported (bug in debugexportstack?)`);
b69ab312016 }
b69ab312017 });
b69ab312018 }
b69ab312019 if (parents.length > 1) {
b69ab312020 throw new Error(`Merge commit ${commit.node} is not supported`);
b69ab312021 }
b69ab312022 knownNodes.add(commit.node);
b69ab312023 });
b69ab312024 if (knownNodes.size != stack.length) {
b69ab312025 throw new Error('Commit stack has duplicated nodes (bug in debugexportstack?)');
b69ab312026 }
b69ab312027}
b69ab312028
b69ab312029/** Rewrite fields that contains `rev` based on the mapping function. */
b69ab312030function rewriteCommitRevs(
b69ab312031 commit: CommitState,
b69ab312032 revMapFunc: (rev: CommitRev) => CommitRev,
b69ab312033): CommitState {
b69ab312034 return commit.merge({
b69ab312035 rev: revMapFunc(commit.rev),
b69ab312036 parents: commit.parents.map(revMapFunc),
b69ab312037 });
b69ab312038}
b69ab312039
b69ab312040/** Guess if commit message is meaningful. Messages like "wip" or "fixup" are meaningless. */
b69ab312041function isMeaningfulText(text: string): boolean {
b69ab312042 const trimmed = text.trim();
b69ab312043 return trimmed.includes(' ') || trimmed.includes('\n') || trimmed.length > 20;
b69ab312044}
b69ab312045
b69ab312046/**
b69ab312047 * Turn distinct numbers to a 0..n sequence preserving the order.
b69ab312048 * For example, turn [0, 100, 50] into [0, 2, 1].
b69ab312049 * This could convert CommitRevs to FileRevs, assuming the file
b69ab312050 * stack is a sub-sequence of the commit sequence.
b69ab312051 */
b69ab312052function compactSequence(revs: CommitRev[]): FileRev[] {
b69ab312053 const sortedRevs = [...revs].sort((aRev, bRev) => aRev - bRev);
b69ab312054 return revs.map(rev => sortedRevs.indexOf(rev) as FileRev);
b69ab312055}
b69ab312056
b69ab312057/** Reorder rev and rev + 1. Return [] if rev is out of range */
b69ab312058export function reorderedRevs(state: CommitStackState, rev: number): CommitRev[] {
b69ab312059 // Basically, `toSpliced`, but it's not available everywhere.
b69ab312060 const order = state.revs();
b69ab312061 if (rev < 0 || rev >= order.length - 1) {
b69ab312062 return [];
b69ab312063 }
b69ab312064 const rev1 = order[rev];
b69ab312065 const rev2 = order[rev + 1];
b69ab312066 order.splice(rev, 2, rev2, rev1);
b69ab312067 return order;
b69ab312068}
b69ab312069
b69ab312070type BuildFileStackOptions = {followRenames?: boolean};
b69ab312071
b69ab312072// Re-export for compatibility
b69ab312073export {
b69ab312074 ABSENT_FILE,
b69ab312075 ABSENT_FLAG,
b69ab312076 Base85,
b69ab312077 CommitIdx,
b69ab312078 CommitState,
b69ab312079 DataRef,
b69ab312080 DateTuple,
b69ab312081 FileIdx,
b69ab312082 FileState,
b69ab312083 isAbsent,
b69ab312084 isContentSame,
b69ab312085 isRename,
b69ab312086 isUtf8,
b69ab312087 toMetadata,
b69ab312088};
b69ab312089export type {CommitRev, FileMetadata, FileRev, FileStackIndex};