| b69ab31 | | | 1 | /** |
| b69ab31 | | | 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. |
| b69ab31 | | | 3 | * |
| b69ab31 | | | 4 | * This source code is licensed under the MIT license found in the |
| b69ab31 | | | 5 | * LICENSE file in the root directory of this source tree. |
| b69ab31 | | | 6 | */ |
| b69ab31 | | | 7 | |
| b69ab31 | | | 8 | import type {RecordOf} from 'immutable'; |
| b69ab31 | | | 9 | import type {Hash, RepoPath} from 'shared/types/common'; |
| b69ab31 | | | 10 | import type { |
| b69ab31 | | | 11 | ExportFile, |
| b69ab31 | | | 12 | ExportStack, |
| b69ab31 | | | 13 | ImportAction, |
| b69ab31 | | | 14 | ImportCommit, |
| b69ab31 | | | 15 | ImportStack, |
| b69ab31 | | | 16 | Mark, |
| b69ab31 | | | 17 | } from 'shared/types/stack'; |
| b69ab31 | | | 18 | import type {AbsorbEdit, AbsorbEditId} from './absorb'; |
| b69ab31 | | | 19 | import type {CommitRev, FileFlag, FileMetadata, FileRev, FileStackIndex} from './common'; |
| b69ab31 | | | 20 | |
| b69ab31 | | | 21 | import deepEqual from 'fast-deep-equal'; |
| b69ab31 | | | 22 | import {Map as ImMap, Set as ImSet, List, Record, Seq, is} from 'immutable'; |
| b69ab31 | | | 23 | import {LRU, cachedMethod} from 'shared/LRU'; |
| b69ab31 | | | 24 | import {SelfUpdate} from 'shared/immutableExt'; |
| b69ab31 | | | 25 | import {firstLine, generatorContains, nullthrows, zip} from 'shared/utils'; |
| b69ab31 | | | 26 | import { |
| b69ab31 | | | 27 | commitMessageFieldsSchema, |
| b69ab31 | | | 28 | commitMessageFieldsToString, |
| b69ab31 | | | 29 | mergeCommitMessageFields, |
| b69ab31 | | | 30 | parseCommitMessageFields, |
| b69ab31 | | | 31 | } from '../CommitInfoView/CommitMessageFields'; |
| b69ab31 | | | 32 | import {WDIR_NODE} from '../dag/virtualCommit'; |
| b69ab31 | | | 33 | import {t} from '../i18n'; |
| b69ab31 | | | 34 | import {readAtom} from '../jotaiUtils'; |
| b69ab31 | | | 35 | import {assert} from '../utils'; |
| b69ab31 | | | 36 | import { |
| b69ab31 | | | 37 | calculateAbsorbEditsForFileStack, |
| b69ab31 | | | 38 | embedAbsorbId, |
| b69ab31 | | | 39 | extractRevAbsorbId, |
| b69ab31 | | | 40 | revWithAbsorb, |
| b69ab31 | | | 41 | } from './absorb'; |
| b69ab31 | | | 42 | import { |
| b69ab31 | | | 43 | ABSENT_FILE, |
| b69ab31 | | | 44 | ABSENT_FLAG, |
| b69ab31 | | | 45 | Base85, |
| b69ab31 | | | 46 | CommitIdx, |
| b69ab31 | | | 47 | CommitState, |
| b69ab31 | | | 48 | DataRef, |
| b69ab31 | | | 49 | DateTuple, |
| b69ab31 | | | 50 | FileIdx, |
| b69ab31 | | | 51 | FileState, |
| b69ab31 | | | 52 | isAbsent, |
| b69ab31 | | | 53 | isContentSame, |
| b69ab31 | | | 54 | isRename, |
| b69ab31 | | | 55 | isUtf8, |
| b69ab31 | | | 56 | toMetadata, |
| b69ab31 | | | 57 | } from './common'; |
| b69ab31 | | | 58 | import {FileStackState} from './fileStackState'; |
| b69ab31 | | | 59 | import {max, next, prev} from './revMath'; |
| b69ab31 | | | 60 | |
| b69ab31 | | | 61 | type CommitStackProps = { |
| b69ab31 | | | 62 | /** |
| b69ab31 | | | 63 | * Original stack exported by `debugexportstack`. Immutable. |
| b69ab31 | | | 64 | * Useful to calculate "predecessor" information. |
| b69ab31 | | | 65 | */ |
| b69ab31 | | | 66 | originalStack: Readonly<ExportStack>; |
| b69ab31 | | | 67 | |
| b69ab31 | | | 68 | /** |
| b69ab31 | | | 69 | * File contents at the bottom of the stack. |
| b69ab31 | | | 70 | * |
| b69ab31 | | | 71 | * For example, when editing stack with two commits A and B: |
| b69ab31 | | | 72 | * |
| b69ab31 | | | 73 | * ``` |
| b69ab31 | | | 74 | * B <- draft, rev 2 |
| b69ab31 | | | 75 | * | |
| b69ab31 | | | 76 | * A <- draft, modifies foo.txt, rev 1 |
| b69ab31 | | | 77 | * / |
| b69ab31 | | | 78 | * P <- public, does not modify foo.txt, rev 0 |
| b69ab31 | | | 79 | * ``` |
| b69ab31 | | | 80 | * |
| b69ab31 | | | 81 | * `bottomFiles['foo.txt']` would be the `foo.txt` content in P, |
| b69ab31 | | | 82 | * despite P does not change `foo.txt`. |
| b69ab31 | | | 83 | * |
| b69ab31 | | | 84 | * `bottomFiles` are considered immutable - stack editing operations |
| b69ab31 | | | 85 | * won't change `bottomFiles` directly. |
| b69ab31 | | | 86 | * |
| b69ab31 | | | 87 | * This also assumes there are only one root of the stack. |
| b69ab31 | | | 88 | * |
| b69ab31 | | | 89 | * This implies that: every file referenced or edited by any commit |
| b69ab31 | | | 90 | * in the stack will be present in this map. If a file was added |
| b69ab31 | | | 91 | * later in the stack, it is in this map and marked as absent. |
| b69ab31 | | | 92 | */ |
| b69ab31 | | | 93 | bottomFiles: Readonly<Map<RepoPath, FileState>>; |
| b69ab31 | | | 94 | |
| b69ab31 | | | 95 | /** |
| b69ab31 | | | 96 | * Mutable commit stack. Indexed by rev. |
| b69ab31 | | | 97 | * Only stores "modified (added, edited, deleted)" files. |
| b69ab31 | | | 98 | */ |
| b69ab31 | | | 99 | stack: List<CommitState>; |
| b69ab31 | | | 100 | |
| b69ab31 | | | 101 | /** |
| b69ab31 | | | 102 | * File stack states. |
| b69ab31 | | | 103 | * They are constructed on demand, and provide advanced features. |
| b69ab31 | | | 104 | */ |
| b69ab31 | | | 105 | fileStacks: List<FileStackState>; |
| b69ab31 | | | 106 | |
| b69ab31 | | | 107 | /** |
| b69ab31 | | | 108 | * Map from `CommitIdx` (commitRev and path) to `FileIdx` (FileStack index and rev). |
| b69ab31 | | | 109 | * Note the commitRev could be -1, meaning that `bottomFiles` is used. |
| b69ab31 | | | 110 | */ |
| b69ab31 | | | 111 | commitToFile: ImMap<CommitIdx, FileIdx>; |
| b69ab31 | | | 112 | |
| b69ab31 | | | 113 | /** |
| b69ab31 | | | 114 | * Reverse (swapped key and value) mapping of `commitToFile` mapping. |
| b69ab31 | | | 115 | * Note the commitRev could be -1, meaning that `bottomFiles` is used. |
| b69ab31 | | | 116 | */ |
| b69ab31 | | | 117 | fileToCommit: ImMap<FileIdx, CommitIdx>; |
| b69ab31 | | | 118 | |
| b69ab31 | | | 119 | /** |
| b69ab31 | | | 120 | * Extra information for absorb. |
| b69ab31 | | | 121 | * |
| b69ab31 | | | 122 | * The state might also be calculated from the linelog file stacks |
| b69ab31 | | | 123 | * (by editing the linelogs, and calculating diffs). It's also tracked here |
| b69ab31 | | | 124 | * for ease-of-access. |
| b69ab31 | | | 125 | */ |
| b69ab31 | | | 126 | absorbExtra: AbsorbExtra; |
| b69ab31 | | | 127 | }; |
| b69ab31 | | | 128 | |
| b69ab31 | | | 129 | // Factory function for creating instances. |
| b69ab31 | | | 130 | // Its type is the factory function (or the "class type" in OOP sense). |
| b69ab31 | | | 131 | const CommitStackRecord = Record<CommitStackProps>({ |
| b69ab31 | | | 132 | originalStack: [], |
| b69ab31 | | | 133 | bottomFiles: new Map(), |
| b69ab31 | | | 134 | stack: List(), |
| b69ab31 | | | 135 | fileStacks: List(), |
| b69ab31 | | | 136 | commitToFile: ImMap(), |
| b69ab31 | | | 137 | fileToCommit: ImMap(), |
| b69ab31 | | | 138 | absorbExtra: ImMap(), |
| b69ab31 | | | 139 | }); |
| b69ab31 | | | 140 | |
| b69ab31 | | | 141 | /** |
| b69ab31 | | | 142 | * For absorb use-case, each file stack (keyed by the index of fileStacks) has |
| b69ab31 | | | 143 | * an AbsorbEditId->AbsorbEdit mapping. |
| b69ab31 | | | 144 | */ |
| b69ab31 | | | 145 | type AbsorbExtra = ImMap<FileStackIndex, ImMap<AbsorbEditId, AbsorbEdit>>; |
| b69ab31 | | | 146 | |
| b69ab31 | | | 147 | // Type of *instances* created by the `CommitStackRecord`. |
| b69ab31 | | | 148 | // This makes `CommitStackState` work more like a common OOP `class Foo`: |
| b69ab31 | | | 149 | // `new Foo(...)` is a constructor, and `Foo` is the type of the instances, |
| b69ab31 | | | 150 | // not the constructor or factory. |
| b69ab31 | | | 151 | type CommitStackRecord = RecordOf<CommitStackProps>; |
| b69ab31 | | | 152 | |
| b69ab31 | | | 153 | /** |
| b69ab31 | | | 154 | * A stack of commits with stack editing features. |
| b69ab31 | | | 155 | * |
| b69ab31 | | | 156 | * Provides read write APIs for editing the stack. |
| b69ab31 | | | 157 | * Under the hood, continuous changes to a same file are grouped |
| b69ab31 | | | 158 | * to file stacks. Part of analysis and edit operations are delegated |
| b69ab31 | | | 159 | * to corresponding file stacks. |
| b69ab31 | | | 160 | */ |
| b69ab31 | | | 161 | export class CommitStackState extends SelfUpdate<CommitStackRecord> { |
| b69ab31 | | | 162 | // Initial setup. |
| b69ab31 | | | 163 | |
| b69ab31 | | | 164 | /** |
| b69ab31 | | | 165 | * Construct from an exported stack. For efficient operations, |
| b69ab31 | | | 166 | * call `.buildFileStacks()` to build up states. |
| b69ab31 | | | 167 | * |
| b69ab31 | | | 168 | * `record` initialization is for internal use only. |
| b69ab31 | | | 169 | */ |
| b69ab31 | | | 170 | constructor(originalStack?: Readonly<ExportStack>, record?: CommitStackRecord) { |
| b69ab31 | | | 171 | super( |
| b69ab31 | | | 172 | originalStack !== undefined |
| b69ab31 | | | 173 | ? CommitStackRecord({ |
| b69ab31 | | | 174 | originalStack, |
| b69ab31 | | | 175 | bottomFiles: getBottomFilesFromExportStack(originalStack), |
| b69ab31 | | | 176 | stack: getCommitStatesFromExportStack(originalStack), |
| b69ab31 | | | 177 | }) |
| b69ab31 | | | 178 | : record !== undefined |
| b69ab31 | | | 179 | ? record |
| b69ab31 | | | 180 | : CommitStackRecord(), |
| b69ab31 | | | 181 | ); |
| b69ab31 | | | 182 | } |
| b69ab31 | | | 183 | |
| b69ab31 | | | 184 | // Delegates to SelfUpdate.inner |
| b69ab31 | | | 185 | |
| b69ab31 | | | 186 | get originalStack(): Readonly<ExportStack> { |
| b69ab31 | | | 187 | return this.inner.originalStack; |
| b69ab31 | | | 188 | } |
| b69ab31 | | | 189 | |
| b69ab31 | | | 190 | get bottomFiles(): Readonly<Map<RepoPath, FileState>> { |
| b69ab31 | | | 191 | return this.inner.bottomFiles; |
| b69ab31 | | | 192 | } |
| b69ab31 | | | 193 | |
| b69ab31 | | | 194 | get stack(): List<CommitState> { |
| b69ab31 | | | 195 | return this.inner.stack; |
| b69ab31 | | | 196 | } |
| b69ab31 | | | 197 | |
| b69ab31 | | | 198 | get fileStacks(): List<FileStackState> { |
| b69ab31 | | | 199 | return this.inner.fileStacks; |
| b69ab31 | | | 200 | } |
| b69ab31 | | | 201 | |
| b69ab31 | | | 202 | get commitToFile(): ImMap<CommitIdx, FileIdx> { |
| b69ab31 | | | 203 | return this.inner.commitToFile; |
| b69ab31 | | | 204 | } |
| b69ab31 | | | 205 | |
| b69ab31 | | | 206 | get fileToCommit(): ImMap<FileIdx, CommitIdx> { |
| b69ab31 | | | 207 | return this.inner.fileToCommit; |
| b69ab31 | | | 208 | } |
| b69ab31 | | | 209 | |
| b69ab31 | | | 210 | get absorbExtra(): AbsorbExtra { |
| b69ab31 | | | 211 | return this.inner.absorbExtra; |
| b69ab31 | | | 212 | } |
| b69ab31 | | | 213 | |
| b69ab31 | | | 214 | merge(props: Partial<CommitStackProps>): CommitStackState { |
| b69ab31 | | | 215 | return new CommitStackState(undefined, this.inner.merge(props)); |
| b69ab31 | | | 216 | } |
| b69ab31 | | | 217 | |
| b69ab31 | | | 218 | set<K extends keyof CommitStackProps>(key: K, value: CommitStackProps[K]): CommitStackState { |
| b69ab31 | | | 219 | return new CommitStackState(undefined, this.inner.set(key, value)); |
| b69ab31 | | | 220 | } |
| b69ab31 | | | 221 | |
| b69ab31 | | | 222 | // Read operations. |
| b69ab31 | | | 223 | |
| b69ab31 | | | 224 | /** Returns all valid revs. */ |
| b69ab31 | | | 225 | revs(): CommitRev[] { |
| b69ab31 | | | 226 | return [...this.stack.keys()] as CommitRev[]; |
| b69ab31 | | | 227 | } |
| b69ab31 | | | 228 | |
| b69ab31 | | | 229 | /** Find the first "Rev" that satisfy the condition. */ |
| b69ab31 | | | 230 | findRev(predicate: (commit: CommitState, rev: CommitRev) => boolean): CommitRev | undefined { |
| b69ab31 | | | 231 | return this.stack.findIndex(predicate as (commit: CommitState, rev: number) => boolean) as |
| b69ab31 | | | 232 | | CommitRev |
| b69ab31 | | | 233 | | undefined; |
| b69ab31 | | | 234 | } |
| b69ab31 | | | 235 | |
| b69ab31 | | | 236 | /** Find the last "Rev" that satisfy the condition. */ |
| b69ab31 | | | 237 | findLastRev(predicate: (commit: CommitState, rev: CommitRev) => boolean): CommitRev | undefined { |
| b69ab31 | | | 238 | return this.stack.findLastIndex(predicate as (commit: CommitState, rev: number) => boolean) as |
| b69ab31 | | | 239 | | CommitRev |
| b69ab31 | | | 240 | | undefined; |
| b69ab31 | | | 241 | } |
| b69ab31 | | | 242 | |
| b69ab31 | | | 243 | /** |
| b69ab31 | | | 244 | * Return mutable revs. |
| b69ab31 | | | 245 | * This filters out public or commits outside the original stack export request. |
| b69ab31 | | | 246 | */ |
| b69ab31 | | | 247 | mutableRevs(): CommitRev[] { |
| b69ab31 | | | 248 | return [...this.stack.filter(c => c.immutableKind !== 'hash').map(c => c.rev)]; |
| b69ab31 | | | 249 | } |
| b69ab31 | | | 250 | |
| b69ab31 | | | 251 | /** |
| b69ab31 | | | 252 | * Get the file at the given `rev`. |
| b69ab31 | | | 253 | * |
| b69ab31 | | | 254 | * Returns `ABSENT_FILE` if the file does not exist in the commit. |
| b69ab31 | | | 255 | * Throws if the stack does not have information about the path. |
| b69ab31 | | | 256 | * |
| b69ab31 | | | 257 | * Note this is different from `this.stack[rev].files.get(path)`, |
| b69ab31 | | | 258 | * since `files` only tracks modified files, not existing files |
| b69ab31 | | | 259 | * created from the bottom of the stack. |
| b69ab31 | | | 260 | * |
| b69ab31 | | | 261 | * If `rev` is `-1`, check `bottomFiles`. |
| b69ab31 | | | 262 | */ |
| b69ab31 | | | 263 | getFile(rev: CommitRev, path: RepoPath): FileState { |
| b69ab31 | | | 264 | if (rev > -1) { |
| b69ab31 | | | 265 | for (const logRev of this.log(rev)) { |
| b69ab31 | | | 266 | const commit = this.stack.get(logRev); |
| b69ab31 | | | 267 | if (commit == null) { |
| b69ab31 | | | 268 | return ABSENT_FILE; |
| b69ab31 | | | 269 | } |
| b69ab31 | | | 270 | const file = commit.files.get(path); |
| b69ab31 | | | 271 | if (file !== undefined) { |
| b69ab31 | | | 272 | // Commit modified `file`. |
| b69ab31 | | | 273 | return file; |
| b69ab31 | | | 274 | } |
| b69ab31 | | | 275 | } |
| b69ab31 | | | 276 | } |
| b69ab31 | | | 277 | const file = this.bottomFiles.get(path) ?? ABSENT_FILE; |
| b69ab31 | | | 278 | if (file === undefined) { |
| b69ab31 | | | 279 | throw new Error( |
| b69ab31 | | | 280 | `file ${path} is not tracked by stack (tracked files: ${JSON.stringify( |
| b69ab31 | | | 281 | this.getAllPaths(), |
| b69ab31 | | | 282 | )})`, |
| b69ab31 | | | 283 | ); |
| b69ab31 | | | 284 | } |
| b69ab31 | | | 285 | return file; |
| b69ab31 | | | 286 | } |
| b69ab31 | | | 287 | |
| b69ab31 | | | 288 | /** |
| b69ab31 | | | 289 | * Update a single file without affecting the rest of the stack. |
| b69ab31 | | | 290 | * Use `getFile` to get the `FileState`. |
| b69ab31 | | | 291 | * |
| b69ab31 | | | 292 | * Does some normalization: |
| b69ab31 | | | 293 | * - If a file is non-empty, then "absent" flag will be ignored. |
| b69ab31 | | | 294 | * - If a file is absent, then "copyFrom" and other flags will be ignored. |
| b69ab31 | | | 295 | * - If the "copyFrom" file does not exist in parent, it'll be ignored. |
| b69ab31 | | | 296 | * - If a file is not newly added, "copyFrom" will be ignored. |
| b69ab31 | | | 297 | * |
| b69ab31 | | | 298 | * `rev` cannot be `-1`. `bottomFiles` cannot be modified. |
| b69ab31 | | | 299 | */ |
| b69ab31 | | | 300 | setFile(rev: CommitRev, path: RepoPath, editFile: (f: FileState) => FileState): CommitStackState { |
| b69ab31 | | | 301 | if (rev < 0) { |
| b69ab31 | | | 302 | throw new Error(`invalid rev for setFile: ${rev}`); |
| b69ab31 | | | 303 | } |
| b69ab31 | | | 304 | const origFile = this.getFile(rev, path); |
| b69ab31 | | | 305 | const newFile = editFile(origFile); |
| b69ab31 | | | 306 | let file = newFile; |
| b69ab31 | | | 307 | // Remove 'absent' for non-empty files. |
| b69ab31 | | | 308 | if (isAbsent(file) && this.getUtf8Data(file) !== '') { |
| b69ab31 | | | 309 | const newFlags: FileFlag = file.flags === ABSENT_FLAG ? '' : (file.flags ?? ''); |
| b69ab31 | | | 310 | file = file.set('flags', newFlags); |
| b69ab31 | | | 311 | } |
| b69ab31 | | | 312 | // Remove other flags for absent files. |
| b69ab31 | | | 313 | if (isAbsent(file) && file.flags !== ABSENT_FLAG) { |
| b69ab31 | | | 314 | file = file.set('flags', ABSENT_FLAG); |
| b69ab31 | | | 315 | } |
| b69ab31 | | | 316 | // Check "copyFrom". |
| b69ab31 | | | 317 | const copyFrom = file.copyFrom; |
| b69ab31 | | | 318 | if (copyFrom != null) { |
| b69ab31 | | | 319 | const p1 = this.singleParentRev(rev) ?? (-1 as CommitRev); |
| b69ab31 | | | 320 | if (!isAbsent(this.getFile(p1, path))) { |
| b69ab31 | | | 321 | file = file.remove('copyFrom'); |
| b69ab31 | | | 322 | } else { |
| b69ab31 | | | 323 | const copyFromFile = this.getFile(p1, copyFrom); |
| b69ab31 | | | 324 | if (isAbsent(copyFromFile)) { |
| b69ab31 | | | 325 | file = file.remove('copyFrom'); |
| b69ab31 | | | 326 | } |
| b69ab31 | | | 327 | } |
| b69ab31 | | | 328 | } |
| b69ab31 | | | 329 | let newStack: CommitStackState = this.set( |
| b69ab31 | | | 330 | 'stack', |
| b69ab31 | | | 331 | this.stack.setIn([rev, 'files', path], file), |
| b69ab31 | | | 332 | ); |
| b69ab31 | | | 333 | // Adjust "copyFrom" of child commits. |
| b69ab31 | | | 334 | // If this file is deleted, then child commits cannot copy from it. |
| b69ab31 | | | 335 | if (isAbsent(file) && !isAbsent(origFile)) { |
| b69ab31 | | | 336 | newStack.childRevs(rev).forEach(childRev => { |
| b69ab31 | | | 337 | newStack = newStack.dropCopyFromIf(childRev, (_p, f) => f.copyFrom === path); |
| b69ab31 | | | 338 | }); |
| b69ab31 | | | 339 | } |
| b69ab31 | | | 340 | // If this file is added, then the same path in the child commits cannot use copyFrom. |
| b69ab31 | | | 341 | if (!isAbsent(file) && isAbsent(origFile)) { |
| b69ab31 | | | 342 | newStack.childRevs(rev).forEach(childRev => { |
| b69ab31 | | | 343 | newStack = newStack.dropCopyFromIf(childRev, (p, _f) => p === path); |
| b69ab31 | | | 344 | }); |
| b69ab31 | | | 345 | } |
| b69ab31 | | | 346 | return newStack; |
| b69ab31 | | | 347 | } |
| b69ab31 | | | 348 | |
| b69ab31 | | | 349 | dropCopyFromIf( |
| b69ab31 | | | 350 | rev: CommitRev, |
| b69ab31 | | | 351 | predicate: (path: RepoPath, file: FileState) => boolean, |
| b69ab31 | | | 352 | ): CommitStackState { |
| b69ab31 | | | 353 | const commit = this.stack.get(rev); |
| b69ab31 | | | 354 | if (commit == null) { |
| b69ab31 | | | 355 | return this; |
| b69ab31 | | | 356 | } |
| b69ab31 | | | 357 | const newFiles = commit.files.mapEntries(([path, file]) => { |
| b69ab31 | | | 358 | const newFile = predicate(path, file) ? file.remove('copyFrom') : file; |
| b69ab31 | | | 359 | return [path, newFile]; |
| b69ab31 | | | 360 | }); |
| b69ab31 | | | 361 | const newStack = this.stack.setIn([rev, 'files'], newFiles); |
| b69ab31 | | | 362 | return this.set('stack', newStack); |
| b69ab31 | | | 363 | } |
| b69ab31 | | | 364 | |
| b69ab31 | | | 365 | childRevs(rev: CommitRev): Array<CommitRev> { |
| b69ab31 | | | 366 | const result = []; |
| b69ab31 | | | 367 | for (let i = rev + 1; i < this.stack.size; ++i) { |
| b69ab31 | | | 368 | if (this.stack.get(i)?.parents?.contains(rev)) { |
| b69ab31 | | | 369 | result.push(i as CommitRev); |
| b69ab31 | | | 370 | } |
| b69ab31 | | | 371 | } |
| b69ab31 | | | 372 | return result; |
| b69ab31 | | | 373 | } |
| b69ab31 | | | 374 | |
| b69ab31 | | | 375 | /** |
| b69ab31 | | | 376 | * Get a list of paths changed by a commit. |
| b69ab31 | | | 377 | * |
| b69ab31 | | | 378 | * If `text` is set to `true`, only return text (content editable) paths. |
| b69ab31 | | | 379 | * If `text` is set to `false`, only return non-text (not content editable) paths. |
| b69ab31 | | | 380 | */ |
| b69ab31 | | | 381 | getPaths(rev: CommitRev, props?: {text?: boolean}): RepoPath[] { |
| b69ab31 | | | 382 | const commit = this.stack.get(rev); |
| b69ab31 | | | 383 | if (commit == null) { |
| b69ab31 | | | 384 | return []; |
| b69ab31 | | | 385 | } |
| b69ab31 | | | 386 | const text = props?.text; |
| b69ab31 | | | 387 | const result = []; |
| b69ab31 | | | 388 | for (const [path, file] of commit.files) { |
| b69ab31 | | | 389 | if (text != null && isUtf8(file) !== text) { |
| b69ab31 | | | 390 | continue; |
| b69ab31 | | | 391 | } |
| b69ab31 | | | 392 | result.push(path); |
| b69ab31 | | | 393 | } |
| b69ab31 | | | 394 | return result.sort(); |
| b69ab31 | | | 395 | } |
| b69ab31 | | | 396 | |
| b69ab31 | | | 397 | /** Get all file paths ever referred (via "copy from") or changed in the stack. */ |
| b69ab31 | | | 398 | getAllPaths(): RepoPath[] { |
| b69ab31 | | | 399 | return [...this.bottomFiles.keys()].sort(); |
| b69ab31 | | | 400 | } |
| b69ab31 | | | 401 | |
| b69ab31 | | | 402 | /** List revs, starting from the given rev. */ |
| b69ab31 | | | 403 | *log(startRev: CommitRev): Generator<CommitRev, void> { |
| b69ab31 | | | 404 | const toVisit = [startRev]; |
| b69ab31 | | | 405 | while (true) { |
| b69ab31 | | | 406 | const rev = toVisit.pop(); |
| b69ab31 | | | 407 | if (rev === undefined || rev < 0) { |
| b69ab31 | | | 408 | break; |
| b69ab31 | | | 409 | } |
| b69ab31 | | | 410 | yield rev; |
| b69ab31 | | | 411 | const commit = this.stack.get(rev); |
| b69ab31 | | | 412 | if (commit != null) { |
| b69ab31 | | | 413 | // Visit parent commits. |
| b69ab31 | | | 414 | commit.parents.forEach(parentRev => { |
| b69ab31 | | | 415 | assert(parentRev < rev, 'parent rev must < child to prevent infinite loop in log()'); |
| b69ab31 | | | 416 | toVisit.push(parentRev); |
| b69ab31 | | | 417 | }); |
| b69ab31 | | | 418 | } |
| b69ab31 | | | 419 | } |
| b69ab31 | | | 420 | } |
| b69ab31 | | | 421 | |
| b69ab31 | | | 422 | /** |
| b69ab31 | | | 423 | * List revs that change the given file, starting from the given rev. |
| b69ab31 | | | 424 | * Optionally follow renames. |
| b69ab31 | | | 425 | * Optionally return bottom (rev -1) file. |
| b69ab31 | | | 426 | */ |
| b69ab31 | | | 427 | *logFile( |
| b69ab31 | | | 428 | startRev: CommitRev, |
| b69ab31 | | | 429 | startPath: RepoPath, |
| b69ab31 | | | 430 | followRenames = false, |
| b69ab31 | | | 431 | includeBottom = false, |
| b69ab31 | | | 432 | ): Generator<[CommitRev, RepoPath, FileState], void> { |
| b69ab31 | | | 433 | let path = startPath; |
| b69ab31 | | | 434 | let lastFile = undefined; |
| b69ab31 | | | 435 | let lastPath = path; |
| b69ab31 | | | 436 | for (const rev of this.log(startRev)) { |
| b69ab31 | | | 437 | const commit = this.stack.get(rev); |
| b69ab31 | | | 438 | if (commit == null) { |
| b69ab31 | | | 439 | continue; |
| b69ab31 | | | 440 | } |
| b69ab31 | | | 441 | const file = commit.files.get(path); |
| b69ab31 | | | 442 | if (file !== undefined) { |
| b69ab31 | | | 443 | yield [rev, path, file]; |
| b69ab31 | | | 444 | lastFile = file; |
| b69ab31 | | | 445 | lastPath = path; |
| b69ab31 | | | 446 | } |
| b69ab31 | | | 447 | if (followRenames && file?.copyFrom) { |
| b69ab31 | | | 448 | path = file.copyFrom; |
| b69ab31 | | | 449 | } |
| b69ab31 | | | 450 | } |
| b69ab31 | | | 451 | if (includeBottom && lastFile != null) { |
| b69ab31 | | | 452 | const bottomFile = this.bottomFiles.get(path); |
| b69ab31 | | | 453 | if (bottomFile != null && (path !== lastPath || !bottomFile.equals(lastFile))) { |
| b69ab31 | | | 454 | yield [-1 as CommitRev, path, bottomFile]; |
| b69ab31 | | | 455 | } |
| b69ab31 | | | 456 | } |
| b69ab31 | | | 457 | } |
| b69ab31 | | | 458 | |
| b69ab31 | | | 459 | // "Save changes" related. |
| b69ab31 | | | 460 | |
| b69ab31 | | | 461 | /** |
| b69ab31 | | | 462 | * Produce a `ImportStack` useful for the `debugimportstack` command |
| b69ab31 | | | 463 | * to save changes. |
| b69ab31 | | | 464 | * |
| b69ab31 | | | 465 | * Note this function only returns parts that are changed. If nothing is |
| b69ab31 | | | 466 | * changed, this function might return an empty array. |
| b69ab31 | | | 467 | * |
| b69ab31 | | | 468 | * Options: |
| b69ab31 | | | 469 | * - goto: specify a rev or (old commit) to goto. The rev must be changed |
| b69ab31 | | | 470 | * otherwise this parameter is ignored. |
| b69ab31 | | | 471 | * - preserveDirtyFiles: if true, do not change files in the working copy. |
| b69ab31 | | | 472 | * Under the hood, this changes the "goto" to "reset". |
| b69ab31 | | | 473 | * - rewriteDate: if set, the unix timestamp (in seconds) for newly |
| b69ab31 | | | 474 | * created commits. |
| b69ab31 | | | 475 | * - skipWdir: if set, skip changes for the wdir() virtual commit. |
| b69ab31 | | | 476 | * This is desirable for operations like absorb, or "amend --to", |
| b69ab31 | | | 477 | * where the working copy is expected to stay unchanged regardless |
| b69ab31 | | | 478 | * of the current partial/chunk selection. |
| b69ab31 | | | 479 | * |
| b69ab31 | | | 480 | * Example use-cases: |
| b69ab31 | | | 481 | * - Editing a stack (clean working copy): goto = origCurrentHash |
| b69ab31 | | | 482 | * - commit -i: create new rev, goto = maxRev, preserveDirtyFiles = true |
| b69ab31 | | | 483 | * - amend -i, absorb: goto = origCurrentHash, preserveDirtyFiles = true |
| b69ab31 | | | 484 | */ |
| b69ab31 | | | 485 | calculateImportStack(opts?: { |
| b69ab31 | | | 486 | goto?: CommitRev | Hash; |
| b69ab31 | | | 487 | preserveDirtyFiles?: boolean; |
| b69ab31 | | | 488 | rewriteDate?: number; |
| b69ab31 | | | 489 | skipWdir?: boolean; |
| b69ab31 | | | 490 | }): ImportStack { |
| b69ab31 | | | 491 | // Resolve goto to a Rev. |
| b69ab31 | | | 492 | // Special case: if it's at the old stack top, use the new stack top instead. |
| b69ab31 | | | 493 | const gotoRev: CommitRev | undefined = |
| b69ab31 | | | 494 | typeof opts?.goto === 'string' |
| b69ab31 | | | 495 | ? this.originalStack.at(-1)?.node == opts.goto |
| b69ab31 | | | 496 | ? this.stack.last()?.rev |
| b69ab31 | | | 497 | : this.findLastRev(c => c.originalNodes.has(opts.goto as string)) |
| b69ab31 | | | 498 | : opts?.goto; |
| b69ab31 | | | 499 | |
| b69ab31 | | | 500 | // Figure out the first changed rev. |
| b69ab31 | | | 501 | const state = this.useFileContent(); |
| b69ab31 | | | 502 | const originalState = new CommitStackState(state.originalStack); |
| b69ab31 | | | 503 | const firstChangedRev = state.stack.findIndex((commit, i) => { |
| b69ab31 | | | 504 | const originalCommit = originalState.stack.get(i); |
| b69ab31 | | | 505 | return originalCommit == null || !is(commit, originalCommit); |
| b69ab31 | | | 506 | }); |
| b69ab31 | | | 507 | |
| b69ab31 | | | 508 | // Figure out what commits are changed. |
| b69ab31 | | | 509 | let changedCommits: CommitState[] = |
| b69ab31 | | | 510 | firstChangedRev < 0 ? [] : state.stack.slice(firstChangedRev).toArray(); |
| b69ab31 | | | 511 | if (opts?.skipWdir) { |
| b69ab31 | | | 512 | changedCommits = changedCommits.filter(c => !c.originalNodes.contains(WDIR_NODE)); |
| b69ab31 | | | 513 | } |
| b69ab31 | | | 514 | const changedRevs: Set<CommitRev> = new Set(changedCommits.map(c => c.rev)); |
| b69ab31 | | | 515 | const revToMark = (rev: CommitRev): Mark => `:r${rev}`; |
| b69ab31 | | | 516 | const revToMarkOrHash = (rev: CommitRev): Mark | Hash => { |
| b69ab31 | | | 517 | if (changedRevs.has(rev)) { |
| b69ab31 | | | 518 | return revToMark(rev); |
| b69ab31 | | | 519 | } else { |
| b69ab31 | | | 520 | const nodes = nullthrows(state.stack.get(rev)).originalNodes; |
| b69ab31 | | | 521 | assert(nodes.size === 1, 'unchanged commits should have exactly 1 nodes'); |
| b69ab31 | | | 522 | return nullthrows(nodes.first()); |
| b69ab31 | | | 523 | } |
| b69ab31 | | | 524 | }; |
| b69ab31 | | | 525 | |
| b69ab31 | | | 526 | // "commit" new commits based on state.stack. |
| b69ab31 | | | 527 | const actions: ImportAction[] = changedCommits.map(commit => { |
| b69ab31 | | | 528 | assert(commit.immutableKind !== 'hash', 'immutable commits should not be changed'); |
| b69ab31 | | | 529 | const newFiles: {[path: RepoPath]: ExportFile | null} = Object.fromEntries( |
| b69ab31 | | | 530 | [...commit.files.entries()].map(([path, file]) => { |
| b69ab31 | | | 531 | if (isAbsent(file)) { |
| b69ab31 | | | 532 | return [path, null]; |
| b69ab31 | | | 533 | } |
| b69ab31 | | | 534 | const newFile: ExportFile = {}; |
| b69ab31 | | | 535 | if (typeof file.data === 'string') { |
| b69ab31 | | | 536 | newFile.data = file.data; |
| b69ab31 | | | 537 | } else if (file.data instanceof Base85) { |
| b69ab31 | | | 538 | newFile.dataBase85 = file.data.dataBase85; |
| b69ab31 | | | 539 | } else if (file.data instanceof DataRef) { |
| b69ab31 | | | 540 | newFile.dataRef = file.data.toJS(); |
| b69ab31 | | | 541 | } |
| b69ab31 | | | 542 | if (file.copyFrom != null) { |
| b69ab31 | | | 543 | newFile.copyFrom = file.copyFrom; |
| b69ab31 | | | 544 | } |
| b69ab31 | | | 545 | if (file.flags != null) { |
| b69ab31 | | | 546 | newFile.flags = file.flags; |
| b69ab31 | | | 547 | } |
| b69ab31 | | | 548 | return [path, newFile]; |
| b69ab31 | | | 549 | }), |
| b69ab31 | | | 550 | ); |
| b69ab31 | | | 551 | // Ensure the text is not empty with a filler title. |
| b69ab31 | | | 552 | const text = |
| b69ab31 | | | 553 | commit.text.trim().length === 0 || |
| b69ab31 | | | 554 | // if a commit template is used, but the title is not given, then we may have non-title text. |
| b69ab31 | | | 555 | // sl would trim the leading whitespace, which can end up using the commit template as the commit title. |
| b69ab31 | | | 556 | // Instead, use the same filler title. |
| b69ab31 | | | 557 | commit.text[0] === '\n' |
| b69ab31 | | | 558 | ? t('(no title provided)') + commit.text |
| b69ab31 | | | 559 | : commit.text; |
| b69ab31 | | | 560 | const importCommit: ImportCommit = { |
| b69ab31 | | | 561 | mark: revToMark(commit.rev), |
| b69ab31 | | | 562 | author: commit.author, |
| b69ab31 | | | 563 | date: [opts?.rewriteDate ?? commit.date.unix, commit.date.tz], |
| b69ab31 | | | 564 | text, |
| b69ab31 | | | 565 | parents: commit.parents.toArray().map(revToMarkOrHash), |
| b69ab31 | | | 566 | predecessors: commit.originalNodes.toArray().filter(n => n !== WDIR_NODE), |
| b69ab31 | | | 567 | files: newFiles, |
| b69ab31 | | | 568 | }; |
| b69ab31 | | | 569 | return ['commit', importCommit]; |
| b69ab31 | | | 570 | }); |
| b69ab31 | | | 571 | |
| b69ab31 | | | 572 | // "goto" or "reset" as requested. |
| b69ab31 | | | 573 | if (gotoRev != null && changedRevs.has(gotoRev)) { |
| b69ab31 | | | 574 | if (opts?.preserveDirtyFiles) { |
| b69ab31 | | | 575 | actions.push(['reset', {mark: revToMark(gotoRev)}]); |
| b69ab31 | | | 576 | } else { |
| b69ab31 | | | 577 | actions.push(['goto', {mark: revToMark(gotoRev)}]); |
| b69ab31 | | | 578 | } |
| b69ab31 | | | 579 | } |
| b69ab31 | | | 580 | |
| b69ab31 | | | 581 | // "hide" commits that disappear from state.originalStack => state.stack. |
| b69ab31 | | | 582 | // Only requested mutable commits are considered. |
| b69ab31 | | | 583 | const coveredNodes: Set<Hash> = state.stack.reduce((acc, commit) => { |
| b69ab31 | | | 584 | commit.originalNodes.forEach((n: Hash): Set<Hash> => acc.add(n)); |
| b69ab31 | | | 585 | return acc; |
| b69ab31 | | | 586 | }, new Set<Hash>()); |
| b69ab31 | | | 587 | const orphanedNodes: Hash[] = state.originalStack |
| b69ab31 | | | 588 | .filter(c => c.requested && !c.immutable && !coveredNodes.has(c.node)) |
| b69ab31 | | | 589 | .map(c => c.node); |
| b69ab31 | | | 590 | if (orphanedNodes.length > 0) { |
| b69ab31 | | | 591 | actions.push(['hide', {nodes: orphanedNodes}]); |
| b69ab31 | | | 592 | } |
| b69ab31 | | | 593 | |
| b69ab31 | | | 594 | return actions; |
| b69ab31 | | | 595 | } |
| b69ab31 | | | 596 | |
| b69ab31 | | | 597 | // File stack related. |
| b69ab31 | | | 598 | |
| b69ab31 | | | 599 | /** |
| b69ab31 | | | 600 | * Get the parent version of a file and its introducing rev. |
| b69ab31 | | | 601 | * If the returned `rev` is -1, it means the file comes from |
| b69ab31 | | | 602 | * "bottomFiles", aka. its introducing rev is outside the stack. |
| b69ab31 | | | 603 | */ |
| b69ab31 | | | 604 | parentFile( |
| b69ab31 | | | 605 | rev: CommitRev, |
| b69ab31 | | | 606 | path: RepoPath, |
| b69ab31 | | | 607 | followRenames = true, |
| b69ab31 | | | 608 | ): [CommitRev, RepoPath, FileState] { |
| b69ab31 | | | 609 | let prevRev = -1 as CommitRev; |
| b69ab31 | | | 610 | let prevPath = path; |
| b69ab31 | | | 611 | let prevFile = nullthrows(this.bottomFiles.get(path)); |
| b69ab31 | | | 612 | const includeBottom = true; |
| b69ab31 | | | 613 | const logFile = this.logFile(rev, path, followRenames, includeBottom); |
| b69ab31 | | | 614 | for (const [logRev, logPath, file] of logFile) { |
| b69ab31 | | | 615 | if (logRev !== rev) { |
| b69ab31 | | | 616 | [prevRev, prevPath] = [logRev, logPath]; |
| b69ab31 | | | 617 | prevFile = file; |
| b69ab31 | | | 618 | break; |
| b69ab31 | | | 619 | } |
| b69ab31 | | | 620 | } |
| b69ab31 | | | 621 | return [prevRev, prevPath, prevFile]; |
| b69ab31 | | | 622 | } |
| b69ab31 | | | 623 | |
| b69ab31 | | | 624 | /** Assert that the revs are in the right order. */ |
| b69ab31 | | | 625 | assertRevOrder() { |
| b69ab31 | | | 626 | assert( |
| b69ab31 | | | 627 | this.stack.every(c => c.parents.every(p => p < c.rev)), |
| b69ab31 | | | 628 | 'parent rev should < child rev', |
| b69ab31 | | | 629 | ); |
| b69ab31 | | | 630 | assert( |
| b69ab31 | | | 631 | this.stack.every((c, i) => c.rev === i), |
| b69ab31 | | | 632 | 'rev should equal to stack index', |
| b69ab31 | | | 633 | ); |
| b69ab31 | | | 634 | } |
| b69ab31 | | | 635 | |
| b69ab31 | | | 636 | // Absorb related {{{ |
| b69ab31 | | | 637 | |
| b69ab31 | | | 638 | /** Check if there is a pending absorb in this stack */ |
| b69ab31 | | | 639 | hasPendingAbsorb(): boolean { |
| b69ab31 | | | 640 | return !this.inner.absorbExtra.isEmpty(); |
| b69ab31 | | | 641 | } |
| b69ab31 | | | 642 | |
| b69ab31 | | | 643 | /** |
| b69ab31 | | | 644 | * Prepare for absorb use-case. Break down "wdir()" edits into the stack |
| b69ab31 | | | 645 | * with special revs so they can be later moved around. |
| b69ab31 | | | 646 | * See `calculateAbsorbEditsForFileStack` for details. |
| b69ab31 | | | 647 | * |
| b69ab31 | | | 648 | * This function assumes the stack top is "wdir()" to absorb, and the stack |
| b69ab31 | | | 649 | * bottom is immutable (public()). |
| b69ab31 | | | 650 | */ |
| b69ab31 | | | 651 | analyseAbsorb(): CommitStackState { |
| b69ab31 | | | 652 | const stack = this.useFileStack(); |
| b69ab31 | | | 653 | const wdirCommitRev = stack.stack.size - 1; |
| b69ab31 | | | 654 | assert(wdirCommitRev > 0, 'stack cannot be empty'); |
| b69ab31 | | | 655 | let newFileStacks = stack.fileStacks; |
| b69ab31 | | | 656 | let absorbExtra: AbsorbExtra = ImMap(); |
| b69ab31 | | | 657 | stack.fileStacks.forEach((fileStack, fileIdx) => { |
| b69ab31 | | | 658 | const topFileRev = prev(fileStack.revLength); |
| b69ab31 | | | 659 | if (topFileRev < 0) { |
| b69ab31 | | | 660 | // Empty file stack. Skip. |
| b69ab31 | | | 661 | return; |
| b69ab31 | | | 662 | } |
| b69ab31 | | | 663 | const rev = stack.fileToCommit.get(FileIdx({fileIdx, fileRev: topFileRev}))?.rev; |
| b69ab31 | | | 664 | if (rev != wdirCommitRev) { |
| b69ab31 | | | 665 | // wdir() did not change this file. Skip. |
| b69ab31 | | | 666 | return; |
| b69ab31 | | | 667 | } |
| b69ab31 | | | 668 | const [newFileStack, absorbMap] = calculateAbsorbEditsForFileStack(fileStack, { |
| b69ab31 | | | 669 | fileStackIndex: fileIdx, |
| b69ab31 | | | 670 | }); |
| b69ab31 | | | 671 | absorbExtra = absorbExtra.set(fileIdx, absorbMap); |
| b69ab31 | | | 672 | newFileStacks = newFileStacks.set(fileIdx, newFileStack); |
| b69ab31 | | | 673 | }); |
| b69ab31 | | | 674 | const newStackInner = stack.inner.set('fileStacks', newFileStacks); |
| b69ab31 | | | 675 | const newStack = new CommitStackState(undefined, newStackInner).set('absorbExtra', absorbExtra); |
| b69ab31 | | | 676 | return newStack; |
| b69ab31 | | | 677 | } |
| b69ab31 | | | 678 | |
| b69ab31 | | | 679 | /** |
| b69ab31 | | | 680 | * For an absorb edit, defined by `fileIdx`, and `absorbEditId`, return the |
| b69ab31 | | | 681 | * currently selected and possible "absorb into" commit revs. |
| b69ab31 | | | 682 | * |
| b69ab31 | | | 683 | * The edit can be looked up from the `absorbExtra` state. |
| b69ab31 | | | 684 | */ |
| b69ab31 | | | 685 | getAbsorbCommitRevs( |
| b69ab31 | | | 686 | fileIdx: number, |
| b69ab31 | | | 687 | absorbEditId: AbsorbEditId, |
| b69ab31 | | | 688 | ): {candidateRevs: ReadonlyArray<CommitRev>; selectedRev?: CommitRev} { |
| b69ab31 | | | 689 | const fileStack = nullthrows(this.fileStacks.get(fileIdx)); |
| b69ab31 | | | 690 | const edit = nullthrows(this.absorbExtra.get(fileIdx)?.get(absorbEditId)); |
| b69ab31 | | | 691 | const toCommitRev = (fileRev: FileRev | null | undefined): CommitRev | undefined => { |
| b69ab31 | | | 692 | if (fileRev == null) { |
| b69ab31 | | | 693 | return undefined; |
| b69ab31 | | | 694 | } |
| b69ab31 | | | 695 | return this.fileToCommit.get(FileIdx({fileIdx, fileRev}))?.rev; |
| b69ab31 | | | 696 | }; |
| b69ab31 | | | 697 | // diffChunk uses fileRev, map it to commitRev. |
| b69ab31 | | | 698 | const selectedRev = toCommitRev(edit.selectedRev); |
| b69ab31 | | | 699 | const startCandidateFileRev = Math.max(1, edit.introductionRev); // skip file rev 0 (bottomFiles) |
| b69ab31 | | | 700 | const endCandidateFileRev = fileStack.revLength; |
| b69ab31 | | | 701 | const candidateRevs: CommitRev[] = []; |
| b69ab31 | | | 702 | for (let fileRev = startCandidateFileRev; fileRev <= endCandidateFileRev; ++fileRev) { |
| b69ab31 | | | 703 | const rev = toCommitRev(fileRev as FileRev); |
| b69ab31 | | | 704 | // Skip immutable (public) commits. |
| b69ab31 | | | 705 | if (rev != null && this.get(rev)?.immutableKind !== 'hash') { |
| b69ab31 | | | 706 | candidateRevs.push(rev); |
| b69ab31 | | | 707 | } |
| b69ab31 | | | 708 | } |
| b69ab31 | | | 709 | return {selectedRev, candidateRevs}; |
| b69ab31 | | | 710 | } |
| b69ab31 | | | 711 | |
| b69ab31 | | | 712 | /** |
| b69ab31 | | | 713 | * Filter `absorbExtra` by commit rev. |
| b69ab31 | | | 714 | * |
| b69ab31 | | | 715 | * Only returns a subset of `absorbExtra` that has the `rev` selected. |
| b69ab31 | | | 716 | */ |
| b69ab31 | | | 717 | absorbExtraByCommitRev(rev: CommitRev): AbsorbExtra { |
| b69ab31 | | | 718 | const commit = this.get(rev); |
| b69ab31 | | | 719 | const isWdir = commit?.originalNodes.contains(WDIR_NODE); |
| b69ab31 | | | 720 | return ImMap<FileStackIndex, ImMap<AbsorbEditId, AbsorbEdit>>().withMutations(mut => { |
| b69ab31 | | | 721 | let result = mut; |
| b69ab31 | | | 722 | this.absorbExtra.forEach((edits, fileStackIndex) => { |
| b69ab31 | | | 723 | edits.forEach((edit, editId) => { |
| b69ab31 | | | 724 | assert(edit.absorbEditId === editId, 'absorbEditId should match its map key'); |
| b69ab31 | | | 725 | const fileRev = edit.selectedRev; |
| b69ab31 | | | 726 | const fileIdx = edit.fileStackIndex; |
| b69ab31 | | | 727 | const selectedCommitRev = |
| b69ab31 | | | 728 | fileRev != null && |
| b69ab31 | | | 729 | fileIdx != null && |
| b69ab31 | | | 730 | this.fileToCommit.get(FileIdx({fileIdx, fileRev}))?.rev; |
| b69ab31 | | | 731 | if (selectedCommitRev === rev || (edit.selectedRev == null && isWdir)) { |
| b69ab31 | | | 732 | if (!result.has(fileStackIndex)) { |
| b69ab31 | | | 733 | result = result.set(fileStackIndex, ImMap<AbsorbEditId, AbsorbEdit>()); |
| b69ab31 | | | 734 | } |
| b69ab31 | | | 735 | result = result.setIn([fileStackIndex, editId], edit); |
| b69ab31 | | | 736 | } |
| b69ab31 | | | 737 | }); |
| b69ab31 | | | 738 | }); |
| b69ab31 | | | 739 | return result; |
| b69ab31 | | | 740 | }); |
| b69ab31 | | | 741 | } |
| b69ab31 | | | 742 | |
| b69ab31 | | | 743 | /** |
| b69ab31 | | | 744 | * Calculates the "candidateRevs" for all absorb edits. |
| b69ab31 | | | 745 | * |
| b69ab31 | | | 746 | * For example, in a 26-commit stack A..Z, only C and K changes a.txt, E and J |
| b69ab31 | | | 747 | * changes b.txt. When the user wants to absorb changes from a.txt and b.txt, |
| b69ab31 | | | 748 | * we only show 4 relevant commits: C, E, J, K. |
| b69ab31 | | | 749 | * |
| b69ab31 | | | 750 | * This function does not report public commits. |
| b69ab31 | | | 751 | */ |
| b69ab31 | | | 752 | getAllAbsorbCandidateCommitRevs(): Set<CommitRev> { |
| b69ab31 | | | 753 | const result = new Set<CommitRev>(); |
| b69ab31 | | | 754 | this.absorbExtra.forEach((edits, fileIdx) => { |
| b69ab31 | | | 755 | edits.forEach((_edit, absorbEditId) => { |
| b69ab31 | | | 756 | this.getAbsorbCommitRevs(fileIdx, absorbEditId)?.candidateRevs.forEach(rev => { |
| b69ab31 | | | 757 | if (this.get(rev)?.immutableKind !== 'hash') { |
| b69ab31 | | | 758 | result.add(rev); |
| b69ab31 | | | 759 | } |
| b69ab31 | | | 760 | }); |
| b69ab31 | | | 761 | }); |
| b69ab31 | | | 762 | }); |
| b69ab31 | | | 763 | return result; |
| b69ab31 | | | 764 | } |
| b69ab31 | | | 765 | |
| b69ab31 | | | 766 | /** |
| b69ab31 | | | 767 | * Set `rev` as the "target commit" (amend --to) of an "absorb edit". |
| b69ab31 | | | 768 | * Happens when the user moves the absorb edit among candidate commits. |
| b69ab31 | | | 769 | * |
| b69ab31 | | | 770 | * Throws if the edit cannot be fulfilled, for example, the `commitRev` is |
| b69ab31 | | | 771 | * before the commit introducing the change (conflict), or if the `commitRev` |
| b69ab31 | | | 772 | * does not touch the file being edited (current limitation, might be lifted). |
| b69ab31 | | | 773 | */ |
| b69ab31 | | | 774 | setAbsorbEditDestination( |
| b69ab31 | | | 775 | fileIdx: number, |
| b69ab31 | | | 776 | absorbEditId: AbsorbEditId, |
| b69ab31 | | | 777 | commitRev: CommitRev, |
| b69ab31 | | | 778 | ): CommitStackState { |
| b69ab31 | | | 779 | assert(this.hasPendingAbsorb(), 'stack is not prepared for absorb'); |
| b69ab31 | | | 780 | const fileStack = nullthrows(this.fileStacks.get(fileIdx)); |
| b69ab31 | | | 781 | const edit = nullthrows(this.absorbExtra.get(fileIdx)?.get(absorbEditId)); |
| b69ab31 | | | 782 | const selectedFileRev = edit.selectedRev; |
| b69ab31 | | | 783 | if (selectedFileRev != null) { |
| b69ab31 | | | 784 | const currentCommitRev = this.fileToCommit.get( |
| b69ab31 | | | 785 | FileIdx({fileIdx, fileRev: selectedFileRev}), |
| b69ab31 | | | 786 | )?.rev; |
| b69ab31 | | | 787 | if (currentCommitRev === commitRev) { |
| b69ab31 | | | 788 | // No need to edit. |
| b69ab31 | | | 789 | return this; |
| b69ab31 | | | 790 | } |
| b69ab31 | | | 791 | } |
| b69ab31 | | | 792 | // Figure out the "file rev" from "commit rev", since we don't know the |
| b69ab31 | | | 793 | // "path" of the file at the "commitRev", for now, we just naively looks up |
| b69ab31 | | | 794 | // the fileRev one by one... for now |
| b69ab31 | | | 795 | for (let fileRev = max(edit.introductionRev, 1); ; fileRev = next(fileRev)) { |
| b69ab31 | | | 796 | const candidateCommitRev = this.fileToCommit.get(FileIdx({fileIdx, fileRev}))?.rev; |
| b69ab31 | | | 797 | if (candidateCommitRev == null) { |
| b69ab31 | | | 798 | break; |
| b69ab31 | | | 799 | } |
| b69ab31 | | | 800 | if (candidateCommitRev === commitRev) { |
| b69ab31 | | | 801 | // Update linelog to move the edit to "fileRev". |
| b69ab31 | | | 802 | const newFileRev = embedAbsorbId(fileRev, absorbEditId); |
| b69ab31 | | | 803 | const newFileStack = fileStack.remapRevs(rev => |
| b69ab31 | | | 804 | !Number.isInteger(rev) && extractRevAbsorbId(rev)[1] === absorbEditId ? newFileRev : rev, |
| b69ab31 | | | 805 | ); |
| b69ab31 | | | 806 | // Update the absorb extra too. |
| b69ab31 | | | 807 | const newEdit = edit.set('selectedRev', fileRev); |
| b69ab31 | | | 808 | const newAbsorbExtra = this.absorbExtra.setIn([fileIdx, absorbEditId], newEdit); |
| b69ab31 | | | 809 | // It's possible that "wdir()" is all absorbed, the new stack is |
| b69ab31 | | | 810 | // shorter than the original stack. So we bypass the length check. |
| b69ab31 | | | 811 | const newStack = this.setFileStackInternal(fileIdx, newFileStack).set( |
| b69ab31 | | | 812 | 'absorbExtra', |
| b69ab31 | | | 813 | newAbsorbExtra, |
| b69ab31 | | | 814 | ); |
| b69ab31 | | | 815 | return newStack; |
| b69ab31 | | | 816 | } |
| b69ab31 | | | 817 | } |
| b69ab31 | | | 818 | throw new Error('setAbsorbIntoRev did not find corresponding commit to absorb'); |
| b69ab31 | | | 819 | } |
| b69ab31 | | | 820 | |
| b69ab31 | | | 821 | /** |
| b69ab31 | | | 822 | * Apply pending absorb edits. |
| b69ab31 | | | 823 | * |
| b69ab31 | | | 824 | * After this, absorb edits can no longer be edited by `setAbsorbEditDestination`, |
| b69ab31 | | | 825 | * `hasPendingAbsorb()` returns `false`, and `calculateImportStack()` can be used. |
| b69ab31 | | | 826 | */ |
| b69ab31 | | | 827 | applyAbsorbEdits(): CommitStackState { |
| b69ab31 | | | 828 | if (!this.hasPendingAbsorb()) { |
| b69ab31 | | | 829 | return this; |
| b69ab31 | | | 830 | } |
| b69ab31 | | | 831 | return this.useFileContent().set('absorbExtra', ImMap()); |
| b69ab31 | | | 832 | } |
| b69ab31 | | | 833 | |
| b69ab31 | | | 834 | // }}} (absorb related) |
| b69ab31 | | | 835 | |
| b69ab31 | | | 836 | /** |
| b69ab31 | | | 837 | * (Re-)build file stacks and mappings. |
| b69ab31 | | | 838 | * |
| b69ab31 | | | 839 | * If `followRenames` is true, then attempt to follow renames |
| b69ab31 | | | 840 | * when building linelogs (default: true). |
| b69ab31 | | | 841 | */ |
| b69ab31 | | | 842 | buildFileStacks(opts?: BuildFileStackOptions): CommitStackState { |
| b69ab31 | | | 843 | const fileStacks: FileStackState[] = []; |
| b69ab31 | | | 844 | let commitToFile = ImMap<CommitIdx, FileIdx>(); |
| b69ab31 | | | 845 | let fileToCommit = ImMap<FileIdx, CommitIdx>(); |
| b69ab31 | | | 846 | |
| b69ab31 | | | 847 | const followRenames = opts?.followRenames ?? true; |
| b69ab31 | | | 848 | |
| b69ab31 | | | 849 | this.assertRevOrder(); |
| b69ab31 | | | 850 | |
| b69ab31 | | | 851 | const processFile = ( |
| b69ab31 | | | 852 | state: CommitStackState, |
| b69ab31 | | | 853 | rev: CommitRev, |
| b69ab31 | | | 854 | file: FileState, |
| b69ab31 | | | 855 | path: RepoPath, |
| b69ab31 | | | 856 | ) => { |
| b69ab31 | | | 857 | const [prevRev, prevPath, prevFile] = state.parentFile(rev, path, followRenames); |
| b69ab31 | | | 858 | if (isUtf8(file)) { |
| b69ab31 | | | 859 | // File was added or modified and has utf-8 content. |
| b69ab31 | | | 860 | let fileAppended = false; |
| b69ab31 | | | 861 | if (prevRev >= 0) { |
| b69ab31 | | | 862 | // Try to reuse an existing file stack. |
| b69ab31 | | | 863 | const prev = commitToFile.get(CommitIdx({rev: prevRev, path: prevPath})); |
| b69ab31 | | | 864 | if (prev) { |
| b69ab31 | | | 865 | const prevFileStack = fileStacks[prev.fileIdx]; |
| b69ab31 | | | 866 | // File stack history is linear. Only reuse it if its last |
| b69ab31 | | | 867 | // rev matches `prevFileRev` |
| b69ab31 | | | 868 | if (prevFileStack.source.revLength === prev.fileRev + 1) { |
| b69ab31 | | | 869 | const fileRev = next(prev.fileRev); |
| b69ab31 | | | 870 | fileStacks[prev.fileIdx] = prevFileStack.editText( |
| b69ab31 | | | 871 | fileRev, |
| b69ab31 | | | 872 | state.getUtf8Data(file), |
| b69ab31 | | | 873 | false, |
| b69ab31 | | | 874 | ); |
| b69ab31 | | | 875 | const cIdx = CommitIdx({rev, path}); |
| b69ab31 | | | 876 | const fIdx = FileIdx({fileIdx: prev.fileIdx, fileRev}); |
| b69ab31 | | | 877 | commitToFile = commitToFile.set(cIdx, fIdx); |
| b69ab31 | | | 878 | fileToCommit = fileToCommit.set(fIdx, cIdx); |
| b69ab31 | | | 879 | fileAppended = true; |
| b69ab31 | | | 880 | } |
| b69ab31 | | | 881 | } |
| b69ab31 | | | 882 | } |
| b69ab31 | | | 883 | if (!fileAppended) { |
| b69ab31 | | | 884 | // Cannot reuse an existing file stack. Create a new file stack. |
| b69ab31 | | | 885 | const fileIdx = fileStacks.length; |
| b69ab31 | | | 886 | let fileTextList = [state.getUtf8Data(file)]; |
| b69ab31 | | | 887 | let fileRev = 0 as FileRev; |
| b69ab31 | | | 888 | if (isUtf8(prevFile)) { |
| b69ab31 | | | 889 | // Use "prevFile" as rev 0 (immutable public). |
| b69ab31 | | | 890 | fileTextList = [state.getUtf8Data(prevFile), ...fileTextList]; |
| b69ab31 | | | 891 | const cIdx = CommitIdx({rev: prevRev, path: prevPath}); |
| b69ab31 | | | 892 | const fIdx = FileIdx({fileIdx, fileRev}); |
| b69ab31 | | | 893 | commitToFile = commitToFile.set(cIdx, fIdx); |
| b69ab31 | | | 894 | fileToCommit = fileToCommit.set(fIdx, cIdx); |
| b69ab31 | | | 895 | fileRev = 1 as FileRev; |
| b69ab31 | | | 896 | } |
| b69ab31 | | | 897 | const fileStack = new FileStackState(fileTextList); |
| b69ab31 | | | 898 | fileStacks.push(fileStack); |
| b69ab31 | | | 899 | const cIdx = CommitIdx({rev, path}); |
| b69ab31 | | | 900 | const fIdx = FileIdx({fileIdx, fileRev}); |
| b69ab31 | | | 901 | commitToFile = commitToFile.set(cIdx, fIdx); |
| b69ab31 | | | 902 | fileToCommit = fileToCommit.set(fIdx, cIdx); |
| b69ab31 | | | 903 | } |
| b69ab31 | | | 904 | } |
| b69ab31 | | | 905 | }; |
| b69ab31 | | | 906 | |
| b69ab31 | | | 907 | // Migrate off 'fileStack' type, since we are going to replace the file stacks. |
| b69ab31 | | | 908 | const state = this.useFileContent(); |
| b69ab31 | | | 909 | |
| b69ab31 | | | 910 | state.stack.forEach((commit, revNumber) => { |
| b69ab31 | | | 911 | const rev = revNumber as CommitRev; |
| b69ab31 | | | 912 | const files = commit.files; |
| b69ab31 | | | 913 | // Process order: renames, non-copy, copies. |
| b69ab31 | | | 914 | const priorityFiles: [number, RepoPath, FileState][] = [...files.entries()].map( |
| b69ab31 | | | 915 | ([path, file]) => { |
| b69ab31 | | | 916 | const priority = |
| b69ab31 | | | 917 | followRenames && isRename(commit, path) ? 0 : file.copyFrom == null ? 1 : 2; |
| b69ab31 | | | 918 | return [priority, path, file]; |
| b69ab31 | | | 919 | }, |
| b69ab31 | | | 920 | ); |
| b69ab31 | | | 921 | const renamed = new Set<RepoPath>(); |
| b69ab31 | | | 922 | priorityFiles |
| b69ab31 | | | 923 | .sort(([aPri, aPath, _aFile], [bPri, bPath, _bFile]) => |
| b69ab31 | | | 924 | aPri < bPri || (aPri === bPri && aPath < bPath) ? -1 : 1, |
| b69ab31 | | | 925 | ) |
| b69ab31 | | | 926 | .forEach(([priority, path, file]) => { |
| b69ab31 | | | 927 | // Skip already "renamed" absent files. |
| b69ab31 | | | 928 | let skip = false; |
| b69ab31 | | | 929 | if (priority === 0 && file.copyFrom != null) { |
| b69ab31 | | | 930 | renamed.add(file.copyFrom); |
| b69ab31 | | | 931 | } else { |
| b69ab31 | | | 932 | skip = isAbsent(file) && renamed.has(path); |
| b69ab31 | | | 933 | } |
| b69ab31 | | | 934 | if (!skip) { |
| b69ab31 | | | 935 | processFile(state, rev, file, path); |
| b69ab31 | | | 936 | } |
| b69ab31 | | | 937 | }); |
| b69ab31 | | | 938 | }); |
| b69ab31 | | | 939 | |
| b69ab31 | | | 940 | return state.merge({ |
| b69ab31 | | | 941 | fileStacks: List(fileStacks), |
| b69ab31 | | | 942 | commitToFile, |
| b69ab31 | | | 943 | fileToCommit, |
| b69ab31 | | | 944 | }); |
| b69ab31 | | | 945 | } |
| b69ab31 | | | 946 | |
| b69ab31 | | | 947 | /** |
| b69ab31 | | | 948 | * Build file stacks if it's not present. |
| b69ab31 | | | 949 | * This is part of the `useFileStack` implementation detail. |
| b69ab31 | | | 950 | * It does not ensure the file stack references are actually used for `getFile`. |
| b69ab31 | | | 951 | * For public API, use `useFileStack` instead. |
| b69ab31 | | | 952 | */ |
| b69ab31 | | | 953 | private maybeBuildFileStacks(opts?: BuildFileStackOptions): CommitStackState { |
| b69ab31 | | | 954 | return this.fileStacks.size === 0 ? this.buildFileStacks(opts) : this; |
| b69ab31 | | | 955 | } |
| b69ab31 | | | 956 | |
| b69ab31 | | | 957 | /** |
| b69ab31 | | | 958 | * Switch file contents to use FileStack as source of truth. |
| b69ab31 | | | 959 | * Useful when using FileStack to edit files. |
| b69ab31 | | | 960 | */ |
| b69ab31 | | | 961 | useFileStack(): CommitStackState { |
| b69ab31 | | | 962 | const state = this.maybeBuildFileStacks(); |
| b69ab31 | | | 963 | return state.updateEachFile((rev, file, path) => { |
| b69ab31 | | | 964 | if (typeof file.data === 'string') { |
| b69ab31 | | | 965 | const index = state.commitToFile.get(CommitIdx({rev, path})); |
| b69ab31 | | | 966 | if (index != null) { |
| b69ab31 | | | 967 | return file.set('data', index); |
| b69ab31 | | | 968 | } |
| b69ab31 | | | 969 | } |
| b69ab31 | | | 970 | return file; |
| b69ab31 | | | 971 | }); |
| b69ab31 | | | 972 | } |
| b69ab31 | | | 973 | |
| b69ab31 | | | 974 | /** |
| b69ab31 | | | 975 | * Switch file contents to use string as source of truth. |
| b69ab31 | | | 976 | * Useful when rebuilding FileStack. |
| b69ab31 | | | 977 | */ |
| b69ab31 | | | 978 | useFileContent(): CommitStackState { |
| b69ab31 | | | 979 | return this.updateEachFile((_rev, file) => { |
| b69ab31 | | | 980 | if (typeof file.data !== 'string' && isUtf8(file)) { |
| b69ab31 | | | 981 | const data = this.getUtf8Data(file); |
| b69ab31 | | | 982 | return file.set('data', data); |
| b69ab31 | | | 983 | } |
| b69ab31 | | | 984 | return file; |
| b69ab31 | | | 985 | }).merge({ |
| b69ab31 | | | 986 | fileStacks: List(), |
| b69ab31 | | | 987 | commitToFile: ImMap(), |
| b69ab31 | | | 988 | fileToCommit: ImMap(), |
| b69ab31 | | | 989 | }); |
| b69ab31 | | | 990 | } |
| b69ab31 | | | 991 | |
| b69ab31 | | | 992 | /** |
| b69ab31 | | | 993 | * Iterate through all changed files via the given function. |
| b69ab31 | | | 994 | */ |
| b69ab31 | | | 995 | updateEachFile( |
| b69ab31 | | | 996 | func: (commitRev: CommitRev, file: FileState, path: RepoPath) => FileState, |
| b69ab31 | | | 997 | ): CommitStackState { |
| b69ab31 | | | 998 | const newStack = this.stack.map(commit => { |
| b69ab31 | | | 999 | const newFiles = commit.files.map((file, path) => { |
| b69ab31 | | | 1000 | return func(commit.rev, file, path); |
| b69ab31 | | | 1001 | }); |
| b69ab31 | | | 1002 | return commit.set('files', newFiles); |
| b69ab31 | | | 1003 | }); |
| b69ab31 | | | 1004 | return this.set('stack', newStack); |
| b69ab31 | | | 1005 | } |
| b69ab31 | | | 1006 | |
| b69ab31 | | | 1007 | /** |
| b69ab31 | | | 1008 | * Describe all file stacks for testing purpose. |
| b69ab31 | | | 1009 | * Each returned string represents a file stack. |
| b69ab31 | | | 1010 | * |
| b69ab31 | | | 1011 | * Output in `rev:commit/path(content)` format. |
| b69ab31 | | | 1012 | * If `(content)` is left out it means the file at the rev is absent. |
| b69ab31 | | | 1013 | * If `commit` is `.` then it comes from `bottomFiles` meaning that |
| b69ab31 | | | 1014 | * the commit last modifies the path might be outside the stack. |
| b69ab31 | | | 1015 | * |
| b69ab31 | | | 1016 | * Rev 0 is usually the "public" version that is not editable. |
| b69ab31 | | | 1017 | * |
| b69ab31 | | | 1018 | * For example, `0:./x.txt 1:A/x.txt(33) 2:B/y.txt(33)` means: |
| b69ab31 | | | 1019 | * commit A added `x.txt` with the content `33`, and commit B renamed it to |
| b69ab31 | | | 1020 | * `y.txt`. |
| b69ab31 | | | 1021 | * |
| b69ab31 | | | 1022 | * `0:./z.txt(11) 1:A/z.txt(22) 2:C/z.txt` means: `z.txt` existed at |
| b69ab31 | | | 1023 | * the bottom of the stack with the content `11`. Commit A modified |
| b69ab31 | | | 1024 | * its content to `22` and commit C deleted `z.txt`. |
| b69ab31 | | | 1025 | */ |
| b69ab31 | | | 1026 | describeFileStacks(showContent = true): string[] { |
| b69ab31 | | | 1027 | const state = this.useFileStack(); |
| b69ab31 | | | 1028 | const fileToCommit = state.fileToCommit; |
| b69ab31 | | | 1029 | const stack = state.stack; |
| b69ab31 | | | 1030 | const hasAbsorb = state.hasPendingAbsorb(); |
| b69ab31 | | | 1031 | return state.fileStacks |
| b69ab31 | | | 1032 | .map((fileStack, fileIdx) => { |
| b69ab31 | | | 1033 | return fileStack |
| b69ab31 | | | 1034 | .revs() |
| b69ab31 | | | 1035 | .map(fileRev => { |
| b69ab31 | | | 1036 | const value = fileToCommit.get(FileIdx({fileIdx, fileRev})); |
| b69ab31 | | | 1037 | const spans = [`${fileRev}:`]; |
| b69ab31 | | | 1038 | assert( |
| b69ab31 | | | 1039 | value != null, |
| b69ab31 | | | 1040 | `fileToCommit should have all file stack revs (missing: fileIdx=${fileIdx} fileRev=${fileRev})`, |
| b69ab31 | | | 1041 | ); |
| b69ab31 | | | 1042 | const {rev, path} = value; |
| b69ab31 | | | 1043 | const [commitTitle, absent] = |
| b69ab31 | | | 1044 | rev < 0 |
| b69ab31 | | | 1045 | ? ['.', isAbsent(state.bottomFiles.get(path))] |
| b69ab31 | | | 1046 | : ((c: CommitState): [string, boolean] => [ |
| b69ab31 | | | 1047 | c.text.split('\n').at(0) || [...c.originalNodes].at(0) || '?', |
| b69ab31 | | | 1048 | isAbsent(c.files.get(path)), |
| b69ab31 | | | 1049 | ])(nullthrows(stack.get(rev))); |
| b69ab31 | | | 1050 | spans.push(`${commitTitle}/${path}`); |
| b69ab31 | | | 1051 | if (showContent && !absent) { |
| b69ab31 | | | 1052 | let content = fileStack.getRev(fileRev).replaceAll('\n', '↵'); |
| b69ab31 | | | 1053 | if (hasAbsorb) { |
| b69ab31 | | | 1054 | const absorbedContent = fileStack |
| b69ab31 | | | 1055 | .getRev(revWithAbsorb(fileRev)) |
| b69ab31 | | | 1056 | .replaceAll('\n', '↵'); |
| b69ab31 | | | 1057 | if (absorbedContent !== content) { |
| b69ab31 | | | 1058 | content += `;absorbed:${absorbedContent}`; |
| b69ab31 | | | 1059 | } |
| b69ab31 | | | 1060 | } |
| b69ab31 | | | 1061 | spans.push(`(${content})`); |
| b69ab31 | | | 1062 | } |
| b69ab31 | | | 1063 | return spans.join(''); |
| b69ab31 | | | 1064 | }) |
| b69ab31 | | | 1065 | .join(' '); |
| b69ab31 | | | 1066 | }) |
| b69ab31 | | | 1067 | .toArray(); |
| b69ab31 | | | 1068 | } |
| b69ab31 | | | 1069 | |
| b69ab31 | | | 1070 | /** File name for `fileStacks[index]`. If the file is renamed, return */ |
| b69ab31 | | | 1071 | getFileStackDescription(fileIdx: number): string { |
| b69ab31 | | | 1072 | const fileStack = nullthrows(this.fileStacks.get(fileIdx)); |
| b69ab31 | | | 1073 | const revLength = prev(fileStack.revLength); |
| b69ab31 | | | 1074 | const nameAtFirstRev = this.getFileStackPath(fileIdx, 0 as FileRev); |
| b69ab31 | | | 1075 | const nameAtLastRev = this.getFileStackPath(fileIdx, prev(revLength)); |
| b69ab31 | | | 1076 | const words = []; |
| b69ab31 | | | 1077 | if (nameAtFirstRev) { |
| b69ab31 | | | 1078 | words.push(nameAtFirstRev); |
| b69ab31 | | | 1079 | } |
| b69ab31 | | | 1080 | if (nameAtLastRev && nameAtLastRev !== nameAtFirstRev) { |
| b69ab31 | | | 1081 | // U+2192. Rightwards Arrow (Unicode 1.1). |
| b69ab31 | | | 1082 | words.push('→'); |
| b69ab31 | | | 1083 | words.push(nameAtLastRev); |
| b69ab31 | | | 1084 | } |
| b69ab31 | | | 1085 | if (revLength > 1) { |
| b69ab31 | | | 1086 | words.push(t('(edited by $n commits)', {replace: {$n: revLength.toString()}})); |
| b69ab31 | | | 1087 | } |
| b69ab31 | | | 1088 | return words.join(' '); |
| b69ab31 | | | 1089 | } |
| b69ab31 | | | 1090 | |
| b69ab31 | | | 1091 | /** Get the path name for a specific revision in the given file stack. */ |
| b69ab31 | | | 1092 | getFileStackPath(fileIdx: number, fileRev: FileRev): string | undefined { |
| b69ab31 | | | 1093 | return this.fileToCommit.get(FileIdx({fileIdx, fileRev}))?.path; |
| b69ab31 | | | 1094 | } |
| b69ab31 | | | 1095 | |
| b69ab31 | | | 1096 | /** |
| b69ab31 | | | 1097 | * Get the commit from a file stack revision. |
| b69ab31 | | | 1098 | * Returns undefined when rev is out of range, or the commit is "public" (ex. fileRev is 0). |
| b69ab31 | | | 1099 | */ |
| b69ab31 | | | 1100 | getCommitFromFileStackRev(fileIdx: number, fileRev: FileRev): CommitState | undefined { |
| b69ab31 | | | 1101 | const commitRev = this.fileToCommit.get(FileIdx({fileIdx, fileRev}))?.rev; |
| b69ab31 | | | 1102 | if (commitRev == null || commitRev < 0) { |
| b69ab31 | | | 1103 | return undefined; |
| b69ab31 | | | 1104 | } |
| b69ab31 | | | 1105 | return nullthrows(this.stack.get(commitRev)); |
| b69ab31 | | | 1106 | } |
| b69ab31 | | | 1107 | |
| b69ab31 | | | 1108 | /** |
| b69ab31 | | | 1109 | * Test if a file rev is "absent". An absent file is different from an empty file. |
| b69ab31 | | | 1110 | */ |
| b69ab31 | | | 1111 | isAbsentFromFileStackRev(fileIdx: number, fileRev: FileRev): boolean { |
| b69ab31 | | | 1112 | const commitIdx = this.fileToCommit.get(FileIdx({fileIdx, fileRev})); |
| b69ab31 | | | 1113 | if (commitIdx == null) { |
| b69ab31 | | | 1114 | return true; |
| b69ab31 | | | 1115 | } |
| b69ab31 | | | 1116 | const {rev, path} = commitIdx; |
| b69ab31 | | | 1117 | const file = rev < 0 ? this.bottomFiles.get(path) : this.getFile(rev, path); |
| b69ab31 | | | 1118 | return file == null || isAbsent(file); |
| b69ab31 | | | 1119 | } |
| b69ab31 | | | 1120 | |
| b69ab31 | | | 1121 | /** |
| b69ab31 | | | 1122 | * Extract utf-8 data from a file. |
| b69ab31 | | | 1123 | * Pending absorb is applied if considerPendingAbsorb is true. |
| b69ab31 | | | 1124 | */ |
| b69ab31 | | | 1125 | getUtf8Data(file: FileState, considerPendingAbsorb = true): string { |
| b69ab31 | | | 1126 | if (typeof file.data === 'string') { |
| b69ab31 | | | 1127 | return file.data; |
| b69ab31 | | | 1128 | } |
| b69ab31 | | | 1129 | if (file.data instanceof FileIdx) { |
| b69ab31 | | | 1130 | let fileRev = file.data.fileRev; |
| b69ab31 | | | 1131 | if (considerPendingAbsorb && this.hasPendingAbsorb()) { |
| b69ab31 | | | 1132 | fileRev = revWithAbsorb(fileRev); |
| b69ab31 | | | 1133 | } |
| b69ab31 | | | 1134 | return nullthrows(this.fileStacks.get(file.data.fileIdx)).getRev(fileRev); |
| b69ab31 | | | 1135 | } else { |
| b69ab31 | | | 1136 | throw new Error('getUtf8Data called on non-utf8 file.'); |
| b69ab31 | | | 1137 | } |
| b69ab31 | | | 1138 | } |
| b69ab31 | | | 1139 | |
| b69ab31 | | | 1140 | /** Similar to `getUtf8Data`, but returns `null` if not utf-8 */ |
| b69ab31 | | | 1141 | getUtf8DataOptional(file: FileState, considerPendingAbsorb = true): string | null { |
| b69ab31 | | | 1142 | return isUtf8(file) ? this.getUtf8Data(file, considerPendingAbsorb) : null; |
| b69ab31 | | | 1143 | } |
| b69ab31 | | | 1144 | |
| b69ab31 | | | 1145 | /** Test if two files have the same data. */ |
| b69ab31 | | | 1146 | isEqualFile(a: FileState, b: FileState): boolean { |
| b69ab31 | | | 1147 | if ((a.flags ?? '') !== (b.flags ?? '')) { |
| b69ab31 | | | 1148 | return false; |
| b69ab31 | | | 1149 | } |
| b69ab31 | | | 1150 | if (isUtf8(a) && isUtf8(b)) { |
| b69ab31 | | | 1151 | return this.getUtf8Data(a) === this.getUtf8Data(b); |
| b69ab31 | | | 1152 | } |
| b69ab31 | | | 1153 | // We assume base85 data is immutable, non-utf8 so they won't match utf8 data. |
| b69ab31 | | | 1154 | if (a.data instanceof Base85 && b.data instanceof Base85) { |
| b69ab31 | | | 1155 | return a.data.dataBase85 === b.data.dataBase85; |
| b69ab31 | | | 1156 | } |
| b69ab31 | | | 1157 | if (a.data instanceof DataRef && b.data instanceof DataRef) { |
| b69ab31 | | | 1158 | return is(a.data, b.data); |
| b69ab31 | | | 1159 | } |
| b69ab31 | | | 1160 | return false; |
| b69ab31 | | | 1161 | } |
| b69ab31 | | | 1162 | |
| b69ab31 | | | 1163 | /** Test if the stack is linear. */ |
| b69ab31 | | | 1164 | isStackLinear(): boolean { |
| b69ab31 | | | 1165 | return this.stack.every( |
| b69ab31 | | | 1166 | (commit, rev) => |
| b69ab31 | | | 1167 | rev === 0 || (commit.parents.size === 1 && commit.parents.first() === rev - 1), |
| b69ab31 | | | 1168 | ); |
| b69ab31 | | | 1169 | } |
| b69ab31 | | | 1170 | |
| b69ab31 | | | 1171 | /** Find a commit by key. */ |
| b69ab31 | | | 1172 | findCommitByKey(key: string): CommitState | undefined { |
| b69ab31 | | | 1173 | return this.stack.find(c => c.key === key); |
| b69ab31 | | | 1174 | } |
| b69ab31 | | | 1175 | |
| b69ab31 | | | 1176 | /** Get a specified commit. */ |
| b69ab31 | | | 1177 | get(rev: CommitRev): CommitState | undefined { |
| b69ab31 | | | 1178 | return this.stack.get(rev); |
| b69ab31 | | | 1179 | } |
| b69ab31 | | | 1180 | |
| b69ab31 | | | 1181 | /** Get the stack size. */ |
| b69ab31 | | | 1182 | get size(): number { |
| b69ab31 | | | 1183 | return this.stack.size; |
| b69ab31 | | | 1184 | } |
| b69ab31 | | | 1185 | |
| b69ab31 | | | 1186 | // Histedit-related operations. |
| b69ab31 | | | 1187 | |
| b69ab31 | | | 1188 | /** |
| b69ab31 | | | 1189 | * Calculate the dependencies of revisions. |
| b69ab31 | | | 1190 | * For example, `{5: [3, 1]}` means rev 5 depends on rev 3 and rev 1. |
| b69ab31 | | | 1191 | * |
| b69ab31 | | | 1192 | * This is used to detect what's reasonable when reordering and dropping |
| b69ab31 | | | 1193 | * commits. For example, if rev 3 depends on rev 2, then rev 3 cannot be |
| b69ab31 | | | 1194 | * moved to be an ancestor of rev 2, and rev 2 cannot be dropped alone. |
| b69ab31 | | | 1195 | */ |
| b69ab31 | | | 1196 | calculateDepMap = cachedMethod(this.calculateDepMapImpl, {cache: calculateDepMapCache}); |
| b69ab31 | | | 1197 | private calculateDepMapImpl(): Readonly<Map<CommitRev, Set<CommitRev>>> { |
| b69ab31 | | | 1198 | const state = this.useFileStack(); |
| b69ab31 | | | 1199 | const depMap = new Map<CommitRev, Set<CommitRev>>(state.stack.map(c => [c.rev, new Set()])); |
| b69ab31 | | | 1200 | |
| b69ab31 | | | 1201 | const fileIdxRevToCommitRev = (fileIdx: FileStackIndex, fileRev: FileRev): CommitRev => |
| b69ab31 | | | 1202 | nullthrows(state.fileToCommit.get(FileIdx({fileIdx, fileRev}))).rev; |
| b69ab31 | | | 1203 | |
| b69ab31 | | | 1204 | // Ask FileStack for dependencies about content edits. |
| b69ab31 | | | 1205 | state.fileStacks.forEach((fileStack, fileIdx) => { |
| b69ab31 | | | 1206 | const fileDepMap = fileStack.calculateDepMap(); |
| b69ab31 | | | 1207 | const toCommitRev = (rev: FileRev) => fileIdxRevToCommitRev(fileIdx, rev); |
| b69ab31 | | | 1208 | // Convert file revs to commit revs. |
| b69ab31 | | | 1209 | fileDepMap.forEach((valueFileRevs, keyFileRev) => { |
| b69ab31 | | | 1210 | const keyCommitRev = toCommitRev(keyFileRev); |
| b69ab31 | | | 1211 | if (keyCommitRev >= 0) { |
| b69ab31 | | | 1212 | const set = nullthrows(depMap.get(keyCommitRev)); |
| b69ab31 | | | 1213 | valueFileRevs.forEach(fileRev => { |
| b69ab31 | | | 1214 | const rev = toCommitRev(fileRev); |
| b69ab31 | | | 1215 | if (rev >= 0) { |
| b69ab31 | | | 1216 | set.add(rev); |
| b69ab31 | | | 1217 | } |
| b69ab31 | | | 1218 | }); |
| b69ab31 | | | 1219 | } |
| b69ab31 | | | 1220 | }); |
| b69ab31 | | | 1221 | }); |
| b69ab31 | | | 1222 | |
| b69ab31 | | | 1223 | // Besides, file deletion / addition / renames also introduce dependencies. |
| b69ab31 | | | 1224 | state.stack.forEach(commit => { |
| b69ab31 | | | 1225 | const set = nullthrows(depMap.get(commit.rev)); |
| b69ab31 | | | 1226 | commit.files.forEach((file, path) => { |
| b69ab31 | | | 1227 | const [prevRev, prevPath, prevFile] = state.parentFile(commit.rev, path, true); |
| b69ab31 | | | 1228 | if (prevRev >= 0 && (isAbsent(prevFile) !== isAbsent(file) || prevPath !== path)) { |
| b69ab31 | | | 1229 | set.add(prevRev); |
| b69ab31 | | | 1230 | } |
| b69ab31 | | | 1231 | }); |
| b69ab31 | | | 1232 | }); |
| b69ab31 | | | 1233 | |
| b69ab31 | | | 1234 | return depMap; |
| b69ab31 | | | 1235 | } |
| b69ab31 | | | 1236 | |
| b69ab31 | | | 1237 | /** Return the single parent rev, or null. */ |
| b69ab31 | | | 1238 | singleParentRev(rev: CommitRev): CommitRev | null { |
| b69ab31 | | | 1239 | const commit = this.stack.get(rev); |
| b69ab31 | | | 1240 | const parents = commit?.parents; |
| b69ab31 | | | 1241 | if (parents != null) { |
| b69ab31 | | | 1242 | const parentRev = parents?.first(); |
| b69ab31 | | | 1243 | if (parentRev != null && parents.size === 1) { |
| b69ab31 | | | 1244 | return parentRev; |
| b69ab31 | | | 1245 | } |
| b69ab31 | | | 1246 | } |
| b69ab31 | | | 1247 | return null; |
| b69ab31 | | | 1248 | } |
| b69ab31 | | | 1249 | |
| b69ab31 | | | 1250 | /** |
| b69ab31 | | | 1251 | * Test if the commit can be folded with its parent. |
| b69ab31 | | | 1252 | */ |
| b69ab31 | | | 1253 | canFoldDown = cachedMethod(this.canFoldDownImpl, {cache: canFoldDownCache}); |
| b69ab31 | | | 1254 | private canFoldDownImpl(rev: CommitRev): boolean { |
| b69ab31 | | | 1255 | if (rev <= 0) { |
| b69ab31 | | | 1256 | return false; |
| b69ab31 | | | 1257 | } |
| b69ab31 | | | 1258 | const commit = this.stack.get(rev); |
| b69ab31 | | | 1259 | if (commit == null) { |
| b69ab31 | | | 1260 | return false; |
| b69ab31 | | | 1261 | } |
| b69ab31 | | | 1262 | const parentRev = this.singleParentRev(rev); |
| b69ab31 | | | 1263 | if (parentRev == null) { |
| b69ab31 | | | 1264 | return false; |
| b69ab31 | | | 1265 | } |
| b69ab31 | | | 1266 | const parent = nullthrows(this.stack.get(parentRev)); |
| b69ab31 | | | 1267 | if (commit.immutableKind !== 'none' || parent.immutableKind !== 'none') { |
| b69ab31 | | | 1268 | return false; |
| b69ab31 | | | 1269 | } |
| b69ab31 | | | 1270 | // This is a bit conservative. But we're not doing complex content check for now. |
| b69ab31 | | | 1271 | const childCount = this.stack.count(c => c.parents.includes(parentRev)); |
| b69ab31 | | | 1272 | if (childCount > 1) { |
| b69ab31 | | | 1273 | return false; |
| b69ab31 | | | 1274 | } |
| b69ab31 | | | 1275 | return true; |
| b69ab31 | | | 1276 | } |
| b69ab31 | | | 1277 | |
| b69ab31 | | | 1278 | /** |
| b69ab31 | | | 1279 | * Drop the given `rev`. |
| b69ab31 | | | 1280 | * The callsite should take care of `files` updates. |
| b69ab31 | | | 1281 | */ |
| b69ab31 | | | 1282 | rewriteStackDroppingRev(rev: CommitRev): CommitStackState { |
| b69ab31 | | | 1283 | const revMapFunc = (r: CommitRev) => (r < rev ? r : prev(r)); |
| b69ab31 | | | 1284 | const newStack = this.stack |
| b69ab31 | | | 1285 | .filter(c => c.rev !== rev) |
| b69ab31 | | | 1286 | .map(c => rewriteCommitRevs(c, revMapFunc)); |
| b69ab31 | | | 1287 | // Recalculate file stacks. |
| b69ab31 | | | 1288 | return this.set('stack', newStack).buildFileStacks(); |
| b69ab31 | | | 1289 | } |
| b69ab31 | | | 1290 | |
| b69ab31 | | | 1291 | /** |
| b69ab31 | | | 1292 | * Fold the commit with its parent. |
| b69ab31 | | | 1293 | * This should only be called when `canFoldDown(rev)` returned `true`. |
| b69ab31 | | | 1294 | */ |
| b69ab31 | | | 1295 | foldDown(rev: CommitRev) { |
| b69ab31 | | | 1296 | const commit = nullthrows(this.stack.get(rev)); |
| b69ab31 | | | 1297 | const parentRev = nullthrows(this.singleParentRev(rev)); |
| b69ab31 | | | 1298 | const parent = nullthrows(this.stack.get(parentRev)); |
| b69ab31 | | | 1299 | let newParentFiles = parent.files; |
| b69ab31 | | | 1300 | const newFiles = commit.files.map((origFile, path) => { |
| b69ab31 | | | 1301 | // Fold copyFrom. `-` means "no change". |
| b69ab31 | | | 1302 | // |
| b69ab31 | | | 1303 | // | grand | direct | | | |
| b69ab31 | | | 1304 | // | parent | parent | rev | folded (copyFrom) | |
| b69ab31 | | | 1305 | // +--------------------------------------------+ |
| b69ab31 | | | 1306 | // | A | A->B | B->C | A->C (parent) | |
| b69ab31 | | | 1307 | // | A | A->B | B | A->B (parent) | |
| b69ab31 | | | 1308 | // | A | A->B | - | A->B (parent) | |
| b69ab31 | | | 1309 | // | A | A | A->C | A->C (rev) | |
| b69ab31 | | | 1310 | // | A | - | A->C | A->C (rev) | |
| b69ab31 | | | 1311 | // | - | B | B->C | C (drop) | |
| b69ab31 | | | 1312 | let file = origFile; |
| b69ab31 | | | 1313 | const optionalParentFile = newParentFiles.get(file.copyFrom ?? path); |
| b69ab31 | | | 1314 | const copyFrom = optionalParentFile?.copyFrom ?? file.copyFrom; |
| b69ab31 | | | 1315 | if (copyFrom != null && isAbsent(this.parentFile(parentRev, file.copyFrom ?? path)[2])) { |
| b69ab31 | | | 1316 | // "copyFrom" is no longer valid (not existed in grand parent). Drop it. |
| b69ab31 | | | 1317 | file = file.set('copyFrom', undefined); |
| b69ab31 | | | 1318 | } else { |
| b69ab31 | | | 1319 | file = file.set('copyFrom', copyFrom); |
| b69ab31 | | | 1320 | } |
| b69ab31 | | | 1321 | if (this.isEqualFile(this.parentFile(parentRev, path, false /* [1] */)[2], file)) { |
| b69ab31 | | | 1322 | // The file changes cancel out. Remove it. |
| b69ab31 | | | 1323 | // [1]: we need to disable following renames when comparing files for cancel-out check. |
| b69ab31 | | | 1324 | newParentFiles = newParentFiles.delete(path); |
| b69ab31 | | | 1325 | } else { |
| b69ab31 | | | 1326 | // Fold the change of this file. |
| b69ab31 | | | 1327 | newParentFiles = newParentFiles.set(path, file); |
| b69ab31 | | | 1328 | } |
| b69ab31 | | | 1329 | return file; |
| b69ab31 | | | 1330 | }); |
| b69ab31 | | | 1331 | |
| b69ab31 | | | 1332 | // Fold other properties to parent. |
| b69ab31 | | | 1333 | let newParentText = parent.text; |
| b69ab31 | | | 1334 | if (isMeaningfulText(commit.text)) { |
| b69ab31 | | | 1335 | const schema = readAtom(commitMessageFieldsSchema); |
| b69ab31 | | | 1336 | const parentTitle = firstLine(parent.text); |
| b69ab31 | | | 1337 | const parentFields = parseCommitMessageFields( |
| b69ab31 | | | 1338 | schema, |
| b69ab31 | | | 1339 | parentTitle, |
| b69ab31 | | | 1340 | parent.text.slice(parentTitle.length), |
| b69ab31 | | | 1341 | ); |
| b69ab31 | | | 1342 | const commitTitle = firstLine(commit.text); |
| b69ab31 | | | 1343 | const commitFields = parseCommitMessageFields( |
| b69ab31 | | | 1344 | schema, |
| b69ab31 | | | 1345 | commitTitle, |
| b69ab31 | | | 1346 | commit.text.slice(commitTitle.length), |
| b69ab31 | | | 1347 | ); |
| b69ab31 | | | 1348 | const merged = mergeCommitMessageFields(schema, parentFields, commitFields); |
| b69ab31 | | | 1349 | newParentText = commitMessageFieldsToString(schema, merged); |
| b69ab31 | | | 1350 | } |
| b69ab31 | | | 1351 | |
| b69ab31 | | | 1352 | const newParent = parent.merge({ |
| b69ab31 | | | 1353 | text: newParentText, |
| b69ab31 | | | 1354 | date: commit.date, |
| b69ab31 | | | 1355 | originalNodes: parent.originalNodes.merge(commit.originalNodes), |
| b69ab31 | | | 1356 | files: newParentFiles, |
| b69ab31 | | | 1357 | }); |
| b69ab31 | | | 1358 | const newCommit = commit.set('files', newFiles); |
| b69ab31 | | | 1359 | const newStack = this.stack.withMutations(mutStack => { |
| b69ab31 | | | 1360 | mutStack.set(parentRev, newParent).set(rev, newCommit); |
| b69ab31 | | | 1361 | }); |
| b69ab31 | | | 1362 | |
| b69ab31 | | | 1363 | return this.set('stack', newStack).rewriteStackDroppingRev(rev); |
| b69ab31 | | | 1364 | } |
| b69ab31 | | | 1365 | |
| b69ab31 | | | 1366 | /** |
| b69ab31 | | | 1367 | * Test if the commit can be dropped. That is, none of its descendants depend on it. |
| b69ab31 | | | 1368 | */ |
| b69ab31 | | | 1369 | canDrop = cachedMethod(this.canDropImpl, {cache: canDropCache}); |
| b69ab31 | | | 1370 | private canDropImpl(rev: CommitRev): boolean { |
| b69ab31 | | | 1371 | if (rev < 0 || this.stack.get(rev)?.immutableKind !== 'none') { |
| b69ab31 | | | 1372 | return false; |
| b69ab31 | | | 1373 | } |
| b69ab31 | | | 1374 | const depMap = this.calculateDepMap(); |
| b69ab31 | | | 1375 | for (const [currentRev, dependentRevs] of depMap.entries()) { |
| b69ab31 | | | 1376 | if (dependentRevs.has(rev) && generatorContains(this.log(currentRev), rev)) { |
| b69ab31 | | | 1377 | return false; |
| b69ab31 | | | 1378 | } |
| b69ab31 | | | 1379 | } |
| b69ab31 | | | 1380 | return true; |
| b69ab31 | | | 1381 | } |
| b69ab31 | | | 1382 | |
| b69ab31 | | | 1383 | /** |
| b69ab31 | | | 1384 | * Drop a commit. Changes made by the commit will be removed in its |
| b69ab31 | | | 1385 | * descendants. |
| b69ab31 | | | 1386 | * |
| b69ab31 | | | 1387 | * This should only be called when `canDrop(rev)` returned `true`. |
| b69ab31 | | | 1388 | */ |
| b69ab31 | | | 1389 | drop(rev: CommitRev): CommitStackState { |
| b69ab31 | | | 1390 | let state = this.useFileStack().inner; |
| b69ab31 | | | 1391 | const commit = nullthrows(state.stack.get(rev)); |
| b69ab31 | | | 1392 | commit.files.forEach((file, path) => { |
| b69ab31 | | | 1393 | const fileIdxRev: FileIdx | undefined = state.commitToFile.get(CommitIdx({rev, path})); |
| b69ab31 | | | 1394 | if (fileIdxRev != null) { |
| b69ab31 | | | 1395 | const {fileIdx, fileRev} = fileIdxRev; |
| b69ab31 | | | 1396 | const fileStack = nullthrows(state.fileStacks.get(fileIdx)); |
| b69ab31 | | | 1397 | // Drop the rev by remapping it to an unused rev. |
| b69ab31 | | | 1398 | const unusedFileRev = fileStack.source.revLength; |
| b69ab31 | | | 1399 | const newFileStack = fileStack.remapRevs(new Map([[fileRev, unusedFileRev]])); |
| b69ab31 | | | 1400 | state = state.setIn(['fileStacks', fileIdx], newFileStack); |
| b69ab31 | | | 1401 | } |
| b69ab31 | | | 1402 | }); |
| b69ab31 | | | 1403 | |
| b69ab31 | | | 1404 | return new CommitStackState(undefined, state).rewriteStackDroppingRev(rev); |
| b69ab31 | | | 1405 | } |
| b69ab31 | | | 1406 | |
| b69ab31 | | | 1407 | /** |
| b69ab31 | | | 1408 | * Insert an empty commit at `rev`. |
| b69ab31 | | | 1409 | * Cannot insert to an empty stack. |
| b69ab31 | | | 1410 | */ |
| b69ab31 | | | 1411 | insertEmpty(rev: CommitRev, message: string, splitFromRev?: CommitRev): CommitStackState { |
| b69ab31 | | | 1412 | assert(rev <= this.stack.size && rev >= 0, 'rev out of range'); |
| b69ab31 | | | 1413 | const state = this.useFileContent(); |
| b69ab31 | | | 1414 | let newStack; |
| b69ab31 | | | 1415 | const newKey = this.nextKey('insert'); |
| b69ab31 | | | 1416 | const originalNodes = splitFromRev == null ? undefined : state.get(splitFromRev)?.originalNodes; |
| b69ab31 | | | 1417 | if (rev === this.stack.size) { |
| b69ab31 | | | 1418 | const top = this.stack.last(); |
| b69ab31 | | | 1419 | assert(top != null, 'stack cannot be empty'); |
| b69ab31 | | | 1420 | newStack = this.stack.push( |
| b69ab31 | | | 1421 | CommitState({ |
| b69ab31 | | | 1422 | rev, |
| b69ab31 | | | 1423 | parents: List(rev === 0 ? [] : [prev(rev)]), |
| b69ab31 | | | 1424 | text: message, |
| b69ab31 | | | 1425 | key: newKey, |
| b69ab31 | | | 1426 | author: top.author, |
| b69ab31 | | | 1427 | date: top.date, |
| b69ab31 | | | 1428 | originalNodes, |
| b69ab31 | | | 1429 | }), |
| b69ab31 | | | 1430 | ); |
| b69ab31 | | | 1431 | } else { |
| b69ab31 | | | 1432 | const revMapFunc = (r: CommitRev) => (r >= rev ? next(r) : r); |
| b69ab31 | | | 1433 | const origParents = nullthrows(state.stack.get(rev)).parents; |
| b69ab31 | | | 1434 | newStack = state.stack |
| b69ab31 | | | 1435 | .map(c => rewriteCommitRevs(c, revMapFunc)) |
| b69ab31 | | | 1436 | .flatMap(c => { |
| b69ab31 | | | 1437 | if (c.rev == rev + 1) { |
| b69ab31 | | | 1438 | return Seq([ |
| b69ab31 | | | 1439 | CommitState({ |
| b69ab31 | | | 1440 | rev, |
| b69ab31 | | | 1441 | parents: origParents, |
| b69ab31 | | | 1442 | text: message, |
| b69ab31 | | | 1443 | key: newKey, |
| b69ab31 | | | 1444 | author: c.author, |
| b69ab31 | | | 1445 | date: c.date, |
| b69ab31 | | | 1446 | originalNodes, |
| b69ab31 | | | 1447 | }), |
| b69ab31 | | | 1448 | c.set('parents', List([rev])), |
| b69ab31 | | | 1449 | ]); |
| b69ab31 | | | 1450 | } else { |
| b69ab31 | | | 1451 | return Seq([c]); |
| b69ab31 | | | 1452 | } |
| b69ab31 | | | 1453 | }); |
| b69ab31 | | | 1454 | } |
| b69ab31 | | | 1455 | return this.set('stack', newStack).buildFileStacks(); |
| b69ab31 | | | 1456 | } |
| b69ab31 | | | 1457 | |
| b69ab31 | | | 1458 | /** |
| b69ab31 | | | 1459 | * Update commit message. |
| b69ab31 | | | 1460 | */ |
| b69ab31 | | | 1461 | editCommitMessage(rev: CommitRev, message: string): CommitStackState { |
| b69ab31 | | | 1462 | assert(rev <= this.stack.size && rev >= 0, 'rev out of range'); |
| b69ab31 | | | 1463 | const newStack = this.stack.setIn([rev, 'text'], message); |
| b69ab31 | | | 1464 | return this.set('stack', newStack); |
| b69ab31 | | | 1465 | } |
| b69ab31 | | | 1466 | |
| b69ab31 | | | 1467 | /** |
| b69ab31 | | | 1468 | * Find a unique "key" not yet used by the commit stack. |
| b69ab31 | | | 1469 | */ |
| b69ab31 | | | 1470 | nextKey(prefix: string): string { |
| b69ab31 | | | 1471 | const usedKeys = ImSet(this.stack.map(c => c.key)); |
| b69ab31 | | | 1472 | for (let i = 0; ; i++) { |
| b69ab31 | | | 1473 | const key = `${prefix}-${i}`; |
| b69ab31 | | | 1474 | if (usedKeys.has(key)) { |
| b69ab31 | | | 1475 | continue; |
| b69ab31 | | | 1476 | } |
| b69ab31 | | | 1477 | return key; |
| b69ab31 | | | 1478 | } |
| b69ab31 | | | 1479 | } |
| b69ab31 | | | 1480 | |
| b69ab31 | | | 1481 | /** |
| b69ab31 | | | 1482 | * Check if reorder is conflict-free. |
| b69ab31 | | | 1483 | * |
| b69ab31 | | | 1484 | * `order` defines the new order as a "from rev" list. |
| b69ab31 | | | 1485 | * For example, when `this.revs()` is `[0, 1, 2, 3]` and `order` is |
| b69ab31 | | | 1486 | * `[0, 2, 3, 1]`, it means moving the second (rev 1) commit to the |
| b69ab31 | | | 1487 | * stack top. |
| b69ab31 | | | 1488 | * |
| b69ab31 | | | 1489 | * Reordering in a non-linear stack is not supported and will return |
| b69ab31 | | | 1490 | * `false`. This is because it's tricky to describe the desired |
| b69ab31 | | | 1491 | * new parent relationships with just `order`. |
| b69ab31 | | | 1492 | * |
| b69ab31 | | | 1493 | * If `order` is `this.revs()` then no reorder is done. |
| b69ab31 | | | 1494 | */ |
| b69ab31 | | | 1495 | canReorder(order: CommitRev[]): boolean { |
| b69ab31 | | | 1496 | const state = this.useFileStack(); |
| b69ab31 | | | 1497 | if (!state.isStackLinear()) { |
| b69ab31 | | | 1498 | return false; |
| b69ab31 | | | 1499 | } |
| b69ab31 | | | 1500 | if ( |
| b69ab31 | | | 1501 | !deepEqual( |
| b69ab31 | | | 1502 | [...order].sort((a, b) => a - b), |
| b69ab31 | | | 1503 | state.revs(), |
| b69ab31 | | | 1504 | ) |
| b69ab31 | | | 1505 | ) { |
| b69ab31 | | | 1506 | return false; |
| b69ab31 | | | 1507 | } |
| b69ab31 | | | 1508 | |
| b69ab31 | | | 1509 | // "hash" immutable commits cannot be moved. |
| b69ab31 | | | 1510 | if (state.stack.some((commit, rev) => commit.immutableKind === 'hash' && order[rev] !== rev)) { |
| b69ab31 | | | 1511 | return false; |
| b69ab31 | | | 1512 | } |
| b69ab31 | | | 1513 | |
| b69ab31 | | | 1514 | const map = new Map<CommitRev, CommitRev>( |
| b69ab31 | | | 1515 | order.map((fromRev, toRev) => [fromRev as CommitRev, toRev as CommitRev]), |
| b69ab31 | | | 1516 | ); |
| b69ab31 | | | 1517 | // Check dependencies. |
| b69ab31 | | | 1518 | const depMap = state.calculateDepMap(); |
| b69ab31 | | | 1519 | for (const [rev, depRevs] of depMap) { |
| b69ab31 | | | 1520 | const newRev = map.get(rev); |
| b69ab31 | | | 1521 | if (newRev == null) { |
| b69ab31 | | | 1522 | return false; |
| b69ab31 | | | 1523 | } |
| b69ab31 | | | 1524 | for (const depRev of depRevs) { |
| b69ab31 | | | 1525 | const newDepRev = map.get(depRev); |
| b69ab31 | | | 1526 | if (newDepRev == null) { |
| b69ab31 | | | 1527 | return false; |
| b69ab31 | | | 1528 | } |
| b69ab31 | | | 1529 | if (!generatorContains(state.log(newRev), newDepRev)) { |
| b69ab31 | | | 1530 | return false; |
| b69ab31 | | | 1531 | } |
| b69ab31 | | | 1532 | } |
| b69ab31 | | | 1533 | } |
| b69ab31 | | | 1534 | // Passed checks. |
| b69ab31 | | | 1535 | return true; |
| b69ab31 | | | 1536 | } |
| b69ab31 | | | 1537 | |
| b69ab31 | | | 1538 | canMoveDown = cachedMethod(this.canMoveDownImpl, {cache: canMoveDownCache}); |
| b69ab31 | | | 1539 | private canMoveDownImpl(rev: CommitRev): boolean { |
| b69ab31 | | | 1540 | return rev > 0 && this.canMoveUp(prev(rev)); |
| b69ab31 | | | 1541 | } |
| b69ab31 | | | 1542 | |
| b69ab31 | | | 1543 | canMoveUp = cachedMethod(this.canMoveUpImpl, {cache: canMoveUpCache}); |
| b69ab31 | | | 1544 | private canMoveUpImpl(rev: CommitRev): boolean { |
| b69ab31 | | | 1545 | return this.canReorder(reorderedRevs(this, rev)); |
| b69ab31 | | | 1546 | } |
| b69ab31 | | | 1547 | |
| b69ab31 | | | 1548 | /** |
| b69ab31 | | | 1549 | * Reorder stack. Similar to running `histedit`, followed by reordering |
| b69ab31 | | | 1550 | * commits. |
| b69ab31 | | | 1551 | * |
| b69ab31 | | | 1552 | * See `canReorder` for the meaning of `order`. |
| b69ab31 | | | 1553 | * This should only be called when `canReorder(order)` returned `true`. |
| b69ab31 | | | 1554 | */ |
| b69ab31 | | | 1555 | reorder(order: CommitRev[]): CommitStackState { |
| b69ab31 | | | 1556 | const commitRevMap = new Map<CommitRev, CommitRev>( |
| b69ab31 | | | 1557 | order.map((fromRev, toRev) => [fromRev, toRev as CommitRev]), |
| b69ab31 | | | 1558 | ); |
| b69ab31 | | | 1559 | |
| b69ab31 | | | 1560 | // Reorder file contents. This is somewhat tricky involving multiple |
| b69ab31 | | | 1561 | // mappings. Here is an example: |
| b69ab31 | | | 1562 | // |
| b69ab31 | | | 1563 | // Stack: A-B-C-D. Original file contents: [11, 112, 0112, 01312]. |
| b69ab31 | | | 1564 | // Reorder to: A-D-B-C. Expected result: [11, 131, 1312, 01312]. |
| b69ab31 | | | 1565 | // |
| b69ab31 | | | 1566 | // First, we figure out the file stack, and reorder it. The file stack |
| b69ab31 | | | 1567 | // now has the content [11 (A), 131 (B), 1312 (C), 01312 (D)], but the |
| b69ab31 | | | 1568 | // commit stack is still in the A-B-C-D order and refers to the file stack |
| b69ab31 | | | 1569 | // using **fileRev**s. If we blindly reorder the commit stack to A-D-B-C, |
| b69ab31 | | | 1570 | // the resulting files would be [11 (A), 01312 (D), 131 (B), 1312 (C)]. |
| b69ab31 | | | 1571 | // |
| b69ab31 | | | 1572 | // To make it work properly, we apply a reverse mapping (A-D-B-C => |
| b69ab31 | | | 1573 | // A-B-C-D) to the file stack before reordering commits, changing |
| b69ab31 | | | 1574 | // [11 (A), 131 (D), 1312 (B), 01312 (C)] to [11 (A), 1312 (B), 01312 (C), |
| b69ab31 | | | 1575 | // 131 (D)]. So after the commit remapping it produces the desired |
| b69ab31 | | | 1576 | // output. |
| b69ab31 | | | 1577 | let state = this.useFileStack(); |
| b69ab31 | | | 1578 | const newFileStacks = state.fileStacks.map((origFileStack, fileIdx) => { |
| b69ab31 | | | 1579 | let fileStack: FileStackState = origFileStack; |
| b69ab31 | | | 1580 | |
| b69ab31 | | | 1581 | // file revs => commit revs => mapped commit revs => mapped file revs |
| b69ab31 | | | 1582 | const fileRevs = fileStack.revs(); |
| b69ab31 | | | 1583 | const commitRevPaths: CommitIdx[] = fileRevs.map(fileRev => |
| b69ab31 | | | 1584 | nullthrows(state.fileToCommit.get(FileIdx({fileIdx, fileRev}))), |
| b69ab31 | | | 1585 | ); |
| b69ab31 | | | 1586 | const commitRevs: CommitRev[] = commitRevPaths.map(({rev}) => rev); |
| b69ab31 | | | 1587 | const mappedCommitRevs: CommitRev[] = commitRevs.map(rev => commitRevMap.get(rev) ?? rev); |
| b69ab31 | | | 1588 | // commitRevs and mappedCommitRevs might not overlap, although they |
| b69ab31 | | | 1589 | // have the same length (fileRevs.length). Turn them into compact |
| b69ab31 | | | 1590 | // sequence to reason about. |
| b69ab31 | | | 1591 | const fromRevs: FileRev[] = compactSequence(commitRevs); |
| b69ab31 | | | 1592 | const toRevs: FileRev[] = compactSequence(mappedCommitRevs); |
| b69ab31 | | | 1593 | if (deepEqual(fromRevs, toRevs)) { |
| b69ab31 | | | 1594 | return fileStack; |
| b69ab31 | | | 1595 | } |
| b69ab31 | | | 1596 | // Mapping: zip(original revs, mapped file revs) |
| b69ab31 | | | 1597 | const fileRevMap = new Map<FileRev, FileRev>(zip(fromRevs, toRevs)); |
| b69ab31 | | | 1598 | fileStack = fileStack.remapRevs(fileRevMap); |
| b69ab31 | | | 1599 | // Apply the reverse mapping. See the above comment for why this is necessary. |
| b69ab31 | | | 1600 | return new FileStackState(fileRevs.map(fileRev => fileStack.getRev(toRevs[fileRev]))); |
| b69ab31 | | | 1601 | }); |
| b69ab31 | | | 1602 | state = state.set('fileStacks', newFileStacks); |
| b69ab31 | | | 1603 | |
| b69ab31 | | | 1604 | // Update state.stack. |
| b69ab31 | | | 1605 | const newStack = state.stack.map((_commit, rev) => { |
| b69ab31 | | | 1606 | const commit = nullthrows(state.stack.get(order[rev])); |
| b69ab31 | | | 1607 | return commit.merge({ |
| b69ab31 | | | 1608 | parents: List(rev > 0 ? [prev(rev as CommitRev)] : []), |
| b69ab31 | | | 1609 | rev: rev as CommitRev, |
| b69ab31 | | | 1610 | }); |
| b69ab31 | | | 1611 | }); |
| b69ab31 | | | 1612 | state = state.set('stack', newStack); |
| b69ab31 | | | 1613 | |
| b69ab31 | | | 1614 | return state.buildFileStacks(); |
| b69ab31 | | | 1615 | } |
| b69ab31 | | | 1616 | |
| b69ab31 | | | 1617 | /** Replace a file stack. Throws if the new stack has a different length. */ |
| b69ab31 | | | 1618 | setFileStack(fileIdx: number, stack: FileStackState): CommitStackState { |
| b69ab31 | | | 1619 | return this.setFileStackInternal(fileIdx, stack, (oldStack, newStack) => { |
| b69ab31 | | | 1620 | assert(oldStack.revLength === newStack.revLength, 'fileStack length mismatch'); |
| b69ab31 | | | 1621 | }); |
| b69ab31 | | | 1622 | } |
| b69ab31 | | | 1623 | |
| b69ab31 | | | 1624 | /** Internal use: replace a file stack. */ |
| b69ab31 | | | 1625 | private setFileStackInternal( |
| b69ab31 | | | 1626 | fileIdx: number, |
| b69ab31 | | | 1627 | stack: FileStackState, |
| b69ab31 | | | 1628 | check?: (oldStack: FileStackState, newStack: FileStackState) => void, |
| b69ab31 | | | 1629 | ): CommitStackState { |
| b69ab31 | | | 1630 | const oldStack = this.fileStacks.get(fileIdx); |
| b69ab31 | | | 1631 | assert(oldStack != null, 'fileIdx out of range'); |
| b69ab31 | | | 1632 | check?.(oldStack, stack); |
| b69ab31 | | | 1633 | const newInner = this.inner.setIn(['fileStacks', fileIdx], stack); |
| b69ab31 | | | 1634 | return new CommitStackState(undefined, newInner); |
| b69ab31 | | | 1635 | } |
| b69ab31 | | | 1636 | |
| b69ab31 | | | 1637 | /** |
| b69ab31 | | | 1638 | * Extract part of the commit stack as a new linear stack. |
| b69ab31 | | | 1639 | * |
| b69ab31 | | | 1640 | * The new stack is "dense" in a way that each commit's "files" |
| b69ab31 | | | 1641 | * include all files every referred by the stack, even if the |
| b69ab31 | | | 1642 | * file is not modified. |
| b69ab31 | | | 1643 | * |
| b69ab31 | | | 1644 | * The new stack: |
| b69ab31 | | | 1645 | * - Does not have "originalStack". |
| b69ab31 | | | 1646 | * - "Dense". Therefore file revs (in fileStacks) map to all |
| b69ab31 | | | 1647 | * commits. |
| b69ab31 | | | 1648 | * - Preserves the rename information, but does not follow renames |
| b69ab31 | | | 1649 | * when building the file stacks. |
| b69ab31 | | | 1650 | * - Preserves non-utf8 files, but does not build into the file |
| b69ab31 | | | 1651 | * stacks, which means their content cannot be edited, but might |
| b69ab31 | | | 1652 | * still be moved around. |
| b69ab31 | | | 1653 | * |
| b69ab31 | | | 1654 | * It is for the interactive split use-case. |
| b69ab31 | | | 1655 | */ |
| b69ab31 | | | 1656 | denseSubStack(revs: List<CommitRev>): CommitStackState { |
| b69ab31 | | | 1657 | const commits = revs.map(rev => this.stack.get(rev)).filter(Boolean) as List<CommitState>; |
| b69ab31 | | | 1658 | const bottomFiles = new Map<RepoPath, FileState>(); |
| b69ab31 | | | 1659 | const followRenames = false; |
| b69ab31 | | | 1660 | |
| b69ab31 | | | 1661 | // Use this.parentFile to populate bottomFiles. |
| b69ab31 | | | 1662 | commits.forEach(commit => { |
| b69ab31 | | | 1663 | const startRev = commit.rev; |
| b69ab31 | | | 1664 | commit.files.forEach((file, startPath) => { |
| b69ab31 | | | 1665 | ([startPath].filter(Boolean) as [string]).forEach(path => { |
| b69ab31 | | | 1666 | if (!bottomFiles.has(path)) { |
| b69ab31 | | | 1667 | const [, , file] = this.parentFile(startRev, path, false); |
| b69ab31 | | | 1668 | bottomFiles.set(path, file); |
| b69ab31 | | | 1669 | } |
| b69ab31 | | | 1670 | if (file.copyFrom != null) { |
| b69ab31 | | | 1671 | const [, fromPath, fromFile] = this.parentFile(startRev, path, true); |
| b69ab31 | | | 1672 | bottomFiles.set(fromPath, fromFile); |
| b69ab31 | | | 1673 | } |
| b69ab31 | | | 1674 | }); |
| b69ab31 | | | 1675 | }); |
| b69ab31 | | | 1676 | }); |
| b69ab31 | | | 1677 | |
| b69ab31 | | | 1678 | // Modify stack: |
| b69ab31 | | | 1679 | // - Re-assign "rev"s (including "parents"). |
| b69ab31 | | | 1680 | // - Assign file contents so files are considered changed in every commit. |
| b69ab31 | | | 1681 | const currentFiles = new Map(bottomFiles); |
| b69ab31 | | | 1682 | const stack: List<CommitState> = commits.map((commit, i) => { |
| b69ab31 | | | 1683 | const newFiles = commit.files.withMutations(mut => { |
| b69ab31 | | | 1684 | let files = mut; |
| b69ab31 | | | 1685 | // Add unchanged files to force treating files as "modified". |
| b69ab31 | | | 1686 | currentFiles.forEach((file, path) => { |
| b69ab31 | | | 1687 | const inCommitFile = files.get(path); |
| b69ab31 | | | 1688 | if (inCommitFile == undefined) { |
| b69ab31 | | | 1689 | // Update files so all files are considered changed and got a file rev assigned. |
| b69ab31 | | | 1690 | files = files.set(path, file ?? ABSENT_FILE); |
| b69ab31 | | | 1691 | } else { |
| b69ab31 | | | 1692 | // Update currentFiles so it can be used by the next commit. |
| b69ab31 | | | 1693 | // Avoid repeating "copyFrom". |
| b69ab31 | | | 1694 | currentFiles.set(path, inCommitFile.remove('copyFrom')); |
| b69ab31 | | | 1695 | } |
| b69ab31 | | | 1696 | }); |
| b69ab31 | | | 1697 | return files; |
| b69ab31 | | | 1698 | }); |
| b69ab31 | | | 1699 | const parents = i === 0 ? List<CommitRev>() : List([prev(i as CommitRev)]); |
| b69ab31 | | | 1700 | return commit.merge({rev: i as CommitRev, files: newFiles, parents}); |
| b69ab31 | | | 1701 | }); |
| b69ab31 | | | 1702 | |
| b69ab31 | | | 1703 | const record = CommitStackRecord({ |
| b69ab31 | | | 1704 | stack, |
| b69ab31 | | | 1705 | bottomFiles, |
| b69ab31 | | | 1706 | }); |
| b69ab31 | | | 1707 | const newStack = new CommitStackState(undefined, record); |
| b69ab31 | | | 1708 | return newStack.buildFileStacks({followRenames}).useFileStack(); |
| b69ab31 | | | 1709 | } |
| b69ab31 | | | 1710 | |
| b69ab31 | | | 1711 | /** |
| b69ab31 | | | 1712 | * Replace the `startRev` (inclusive) to `endRev` (exclusive) sub stack |
| b69ab31 | | | 1713 | * with commits from the `subStack`. |
| b69ab31 | | | 1714 | * |
| b69ab31 | | | 1715 | * Unmodified changes will be dropped. Top commits with empty changes are |
| b69ab31 | | | 1716 | * dropped. This turns a "dense" back to a non-"dense" one. |
| b69ab31 | | | 1717 | * |
| b69ab31 | | | 1718 | * Intended for interactive split use-case. |
| b69ab31 | | | 1719 | */ |
| b69ab31 | | | 1720 | applySubStack( |
| b69ab31 | | | 1721 | startRev: CommitRev, |
| b69ab31 | | | 1722 | endRev: CommitRev, |
| b69ab31 | | | 1723 | subStack: CommitStackState, |
| b69ab31 | | | 1724 | ): CommitStackState { |
| b69ab31 | | | 1725 | assert( |
| b69ab31 | | | 1726 | startRev >= 0 && endRev <= this.stack.size && startRev < endRev, |
| b69ab31 | | | 1727 | 'startRev or endRev out of range', |
| b69ab31 | | | 1728 | ); |
| b69ab31 | | | 1729 | |
| b69ab31 | | | 1730 | const contentSubStack = subStack.useFileContent(); |
| b69ab31 | | | 1731 | const state = this.useFileContent(); |
| b69ab31 | | | 1732 | |
| b69ab31 | | | 1733 | // Used to detect "unchanged" files in subStack. |
| b69ab31 | | | 1734 | const afterFileMap = new Map( |
| b69ab31 | | | 1735 | [...state.bottomFiles.entries()].map(([path, file]) => [path, file]), |
| b69ab31 | | | 1736 | ); |
| b69ab31 | | | 1737 | |
| b69ab31 | | | 1738 | // Used to check the original "final" content of files. |
| b69ab31 | | | 1739 | const beforeFileMap = new Map(afterFileMap); |
| b69ab31 | | | 1740 | |
| b69ab31 | | | 1741 | const updateFileMap = (commit: CommitState, map: Map<string, FileState>) => |
| b69ab31 | | | 1742 | commit.files.forEach((file, path) => map.set(path, file)); |
| b69ab31 | | | 1743 | |
| b69ab31 | | | 1744 | // Pick an unused key. |
| b69ab31 | | | 1745 | const usedKeys = new Set( |
| b69ab31 | | | 1746 | state.stack |
| b69ab31 | | | 1747 | .filter(c => c.rev < startRev || c.rev >= endRev) |
| b69ab31 | | | 1748 | .map(c => c.key) |
| b69ab31 | | | 1749 | .toArray(), |
| b69ab31 | | | 1750 | ); |
| b69ab31 | | | 1751 | const pickKey = (c: CommitState): CommitState => { |
| b69ab31 | | | 1752 | if (usedKeys.has(c.key)) { |
| b69ab31 | | | 1753 | for (let i = 0; ; ++i) { |
| b69ab31 | | | 1754 | const key = `${c.key}-${i}`; |
| b69ab31 | | | 1755 | if (!usedKeys.has(key)) { |
| b69ab31 | | | 1756 | usedKeys.add(c.key); |
| b69ab31 | | | 1757 | return c.set('key', key); |
| b69ab31 | | | 1758 | } |
| b69ab31 | | | 1759 | } |
| b69ab31 | | | 1760 | } else { |
| b69ab31 | | | 1761 | usedKeys.add(c.key); |
| b69ab31 | | | 1762 | return c; |
| b69ab31 | | | 1763 | } |
| b69ab31 | | | 1764 | }; |
| b69ab31 | | | 1765 | |
| b69ab31 | | | 1766 | // Process commits in a "dense" stack. |
| b69ab31 | | | 1767 | // - Update afterFileMap. |
| b69ab31 | | | 1768 | // - Drop unchanged files. |
| b69ab31 | | | 1769 | // - Drop the "absent" flag from files if they are not empty. |
| b69ab31 | | | 1770 | // - Pick a unique key. |
| b69ab31 | | | 1771 | // - Add "parent" for the first commit. |
| b69ab31 | | | 1772 | // - Adjust "revs". |
| b69ab31 | | | 1773 | const processDenseCommit = (c: CommitState): CommitState => { |
| b69ab31 | | | 1774 | const newFiles = c.files.flatMap<RepoPath, FileState>((currentFile, path) => { |
| b69ab31 | | | 1775 | let file: FileState = currentFile; |
| b69ab31 | | | 1776 | const oldFile = afterFileMap.get(path); |
| b69ab31 | | | 1777 | // Drop "absent" flag (and reuse the old flag). |
| b69ab31 | | | 1778 | if ( |
| b69ab31 | | | 1779 | file.flags?.includes(ABSENT_FLAG) && |
| b69ab31 | | | 1780 | typeof file.data === 'string' && |
| b69ab31 | | | 1781 | file.data.length > 0 |
| b69ab31 | | | 1782 | ) { |
| b69ab31 | | | 1783 | let oldFlag = oldFile?.flags; |
| b69ab31 | | | 1784 | if (oldFlag === ABSENT_FLAG) { |
| b69ab31 | | | 1785 | oldFlag = undefined; |
| b69ab31 | | | 1786 | } |
| b69ab31 | | | 1787 | if (oldFlag == null) { |
| b69ab31 | | | 1788 | file = file.remove('flags'); |
| b69ab31 | | | 1789 | } else { |
| b69ab31 | | | 1790 | file = file.set('flags', oldFlag); |
| b69ab31 | | | 1791 | } |
| b69ab31 | | | 1792 | } |
| b69ab31 | | | 1793 | // Drop unchanged files. |
| b69ab31 | | | 1794 | const keep = oldFile == null || !isContentSame(oldFile, file); |
| b69ab31 | | | 1795 | // Update afterFileMap. |
| b69ab31 | | | 1796 | if (keep) { |
| b69ab31 | | | 1797 | afterFileMap.set(path, file); |
| b69ab31 | | | 1798 | } |
| b69ab31 | | | 1799 | return Seq(keep ? [[path, file]] : []); |
| b69ab31 | | | 1800 | }); |
| b69ab31 | | | 1801 | const isFirst = c.rev === 0; |
| b69ab31 | | | 1802 | let commit = rewriteCommitRevs(pickKey(c), r => (r + startRev) as CommitRev).set( |
| b69ab31 | | | 1803 | 'files', |
| b69ab31 | | | 1804 | newFiles, |
| b69ab31 | | | 1805 | ); |
| b69ab31 | | | 1806 | if (isFirst && startRev > 0) { |
| b69ab31 | | | 1807 | commit = commit.set('parents', List([prev(startRev)])); |
| b69ab31 | | | 1808 | } |
| b69ab31 | | | 1809 | return commit; |
| b69ab31 | | | 1810 | }; |
| b69ab31 | | | 1811 | |
| b69ab31 | | | 1812 | // |<--- to delete --->| |
| b69ab31 | | | 1813 | // Before: ... |startRev ... endRev| ... |
| b69ab31 | | | 1814 | // New: ... |filter(substack) | ... |
| b69ab31 | | | 1815 | // filter: remove empty commits |
| b69ab31 | | | 1816 | let newSubStackSize = 0; |
| b69ab31 | | | 1817 | const newStack = state.stack.flatMap(c => { |
| b69ab31 | | | 1818 | updateFileMap(c, beforeFileMap); |
| b69ab31 | | | 1819 | if (c.rev < startRev) { |
| b69ab31 | | | 1820 | updateFileMap(c, afterFileMap); |
| b69ab31 | | | 1821 | return Seq([c]); |
| b69ab31 | | | 1822 | } else if (c.rev === startRev) { |
| b69ab31 | | | 1823 | // dropUnchangedFiles updates afterFileMap. |
| b69ab31 | | | 1824 | let commits = contentSubStack.stack.map(c => processDenseCommit(c)); |
| b69ab31 | | | 1825 | // Drop empty commits at the end. Adjust offset. |
| b69ab31 | | | 1826 | while (commits.last()?.files?.isEmpty()) { |
| b69ab31 | | | 1827 | commits = commits.pop(); |
| b69ab31 | | | 1828 | } |
| b69ab31 | | | 1829 | newSubStackSize = commits.size; |
| b69ab31 | | | 1830 | return commits; |
| b69ab31 | | | 1831 | } else if (c.rev > startRev && c.rev < endRev) { |
| b69ab31 | | | 1832 | return Seq([]); |
| b69ab31 | | | 1833 | } else { |
| b69ab31 | | | 1834 | let commit = c; |
| b69ab31 | | | 1835 | assert(c.rev >= endRev, 'bug: c.rev < endRev should be handled above'); |
| b69ab31 | | | 1836 | if (c.rev === endRev) { |
| b69ab31 | | | 1837 | // This commit should have the same exact content as before, not just the |
| b69ab31 | | | 1838 | // modified files, but also the unmodified ones. |
| b69ab31 | | | 1839 | // We check all files ever changed by the stack between "before" and "after", |
| b69ab31 | | | 1840 | // and bring their content back to "before" in this commit. |
| b69ab31 | | | 1841 | beforeFileMap.forEach((beforeFile, path) => { |
| b69ab31 | | | 1842 | if (commit.files.has(path)) { |
| b69ab31 | | | 1843 | return; |
| b69ab31 | | | 1844 | } |
| b69ab31 | | | 1845 | const afterFile = afterFileMap.get(path); |
| b69ab31 | | | 1846 | if (afterFile == null || !isContentSame(beforeFile, afterFile)) { |
| b69ab31 | | | 1847 | commit = commit.setIn(['files', path], beforeFile); |
| b69ab31 | | | 1848 | } |
| b69ab31 | | | 1849 | }); |
| b69ab31 | | | 1850 | // Delete file added by the subStack that do not exist before. |
| b69ab31 | | | 1851 | afterFileMap.forEach((_, path) => { |
| b69ab31 | | | 1852 | if (!beforeFileMap.has(path)) { |
| b69ab31 | | | 1853 | commit = commit.setIn(['files', path], ABSENT_FILE); |
| b69ab31 | | | 1854 | } |
| b69ab31 | | | 1855 | }); |
| b69ab31 | | | 1856 | } |
| b69ab31 | | | 1857 | const offset = newSubStackSize - (endRev - startRev); |
| b69ab31 | | | 1858 | return Seq([ |
| b69ab31 | | | 1859 | rewriteCommitRevs( |
| b69ab31 | | | 1860 | commit, |
| b69ab31 | | | 1861 | r => ((r >= startRev && r < endRev ? endRev - 1 : r) + offset) as CommitRev, |
| b69ab31 | | | 1862 | ), |
| b69ab31 | | | 1863 | ]); |
| b69ab31 | | | 1864 | } |
| b69ab31 | | | 1865 | }); |
| b69ab31 | | | 1866 | |
| b69ab31 | | | 1867 | // This function might be frequnetly called during interacitve split. |
| b69ab31 | | | 1868 | // Do not build file stacks (potentially slow) now. |
| b69ab31 | | | 1869 | return state.set('stack', newStack); |
| b69ab31 | | | 1870 | } |
| b69ab31 | | | 1871 | |
| b69ab31 | | | 1872 | /** Test if a path at the given rev is a renamed (not copy). */ |
| b69ab31 | | | 1873 | isRename(rev: CommitRev, path: RepoPath): boolean { |
| b69ab31 | | | 1874 | const commit = this.get(rev); |
| b69ab31 | | | 1875 | if (commit == null) { |
| b69ab31 | | | 1876 | return false; |
| b69ab31 | | | 1877 | } |
| b69ab31 | | | 1878 | return isRename(commit, path); |
| b69ab31 | | | 1879 | } |
| b69ab31 | | | 1880 | |
| b69ab31 | | | 1881 | /** |
| b69ab31 | | | 1882 | * If the given file has a metadata change, return the old and new metadata. |
| b69ab31 | | | 1883 | * Otherwise, return undefined. |
| b69ab31 | | | 1884 | */ |
| b69ab31 | | | 1885 | changedFileMetadata( |
| b69ab31 | | | 1886 | rev: CommitRev, |
| b69ab31 | | | 1887 | path: RepoPath, |
| b69ab31 | | | 1888 | followRenames = false, |
| b69ab31 | | | 1889 | ): [FileMetadata, FileMetadata] | undefined { |
| b69ab31 | | | 1890 | const file = this.getFile(rev, path); |
| b69ab31 | | | 1891 | const parentFile = this.parentFile(rev, path, followRenames)[2]; |
| b69ab31 | | | 1892 | const fileMeta = toMetadata(file); |
| b69ab31 | | | 1893 | // Only report "changed" if copyFrom is newly set. |
| b69ab31 | | | 1894 | const parentMeta = toMetadata(parentFile).remove('copyFrom'); |
| b69ab31 | | | 1895 | return fileMeta.equals(parentMeta) ? undefined : [parentMeta, fileMeta]; |
| b69ab31 | | | 1896 | } |
| b69ab31 | | | 1897 | } |
| b69ab31 | | | 1898 | |
| b69ab31 | | | 1899 | const canDropCache = new LRU(1000); |
| b69ab31 | | | 1900 | const calculateDepMapCache = new LRU(1000); |
| b69ab31 | | | 1901 | const canFoldDownCache = new LRU(1000); |
| b69ab31 | | | 1902 | const canMoveUpCache = new LRU(1000); |
| b69ab31 | | | 1903 | const canMoveDownCache = new LRU(1000); |
| b69ab31 | | | 1904 | |
| b69ab31 | | | 1905 | function getBottomFilesFromExportStack(stack: Readonly<ExportStack>): Map<RepoPath, FileState> { |
| b69ab31 | | | 1906 | // bottomFiles requires that the stack only has one root. |
| b69ab31 | | | 1907 | checkStackSingleRoot(stack); |
| b69ab31 | | | 1908 | |
| b69ab31 | | | 1909 | // Calculate bottomFiles. |
| b69ab31 | | | 1910 | const bottomFiles: Map<RepoPath, FileState> = new Map(); |
| b69ab31 | | | 1911 | stack.forEach(commit => { |
| b69ab31 | | | 1912 | for (const [path, file] of Object.entries(commit.relevantFiles ?? {})) { |
| b69ab31 | | | 1913 | if (!bottomFiles.has(path)) { |
| b69ab31 | | | 1914 | bottomFiles.set(path, convertExportFileToFileState(file)); |
| b69ab31 | | | 1915 | } |
| b69ab31 | | | 1916 | } |
| b69ab31 | | | 1917 | |
| b69ab31 | | | 1918 | // Files not yet existed in `bottomFiles` means they are added (in root commits) |
| b69ab31 | | | 1919 | // mark them as "missing" in the stack bottom. |
| b69ab31 | | | 1920 | for (const path of Object.keys(commit.files ?? {})) { |
| b69ab31 | | | 1921 | if (!bottomFiles.has(path)) { |
| b69ab31 | | | 1922 | bottomFiles.set(path, ABSENT_FILE); |
| b69ab31 | | | 1923 | } |
| b69ab31 | | | 1924 | } |
| b69ab31 | | | 1925 | }); |
| b69ab31 | | | 1926 | |
| b69ab31 | | | 1927 | return bottomFiles; |
| b69ab31 | | | 1928 | } |
| b69ab31 | | | 1929 | |
| b69ab31 | | | 1930 | function convertExportFileToFileState(file: ExportFile | null): FileState { |
| b69ab31 | | | 1931 | if (file == null) { |
| b69ab31 | | | 1932 | return ABSENT_FILE; |
| b69ab31 | | | 1933 | } |
| b69ab31 | | | 1934 | return FileState({ |
| b69ab31 | | | 1935 | data: |
| b69ab31 | | | 1936 | file.data != null |
| b69ab31 | | | 1937 | ? file.data |
| b69ab31 | | | 1938 | : file.dataBase85 |
| b69ab31 | | | 1939 | ? Base85({dataBase85: file.dataBase85}) |
| b69ab31 | | | 1940 | : DataRef(nullthrows(file.dataRef)), |
| b69ab31 | | | 1941 | copyFrom: file.copyFrom, |
| b69ab31 | | | 1942 | flags: file.flags, |
| b69ab31 | | | 1943 | }); |
| b69ab31 | | | 1944 | } |
| b69ab31 | | | 1945 | |
| b69ab31 | | | 1946 | function getCommitStatesFromExportStack(stack: Readonly<ExportStack>): List<CommitState> { |
| b69ab31 | | | 1947 | checkStackParents(stack); |
| b69ab31 | | | 1948 | |
| b69ab31 | | | 1949 | // Prepare nodeToRev conversion. |
| b69ab31 | | | 1950 | const revs: CommitRev[] = [...stack.keys()] as CommitRev[]; |
| b69ab31 | | | 1951 | const nodeToRevMap: Map<Hash, CommitRev> = new Map(revs.map(rev => [stack[rev].node, rev])); |
| b69ab31 | | | 1952 | const nodeToRev = (node: Hash): CommitRev => { |
| b69ab31 | | | 1953 | const rev = nodeToRevMap.get(node); |
| b69ab31 | | | 1954 | if (rev == null) { |
| b69ab31 | | | 1955 | throw new Error( |
| b69ab31 | | | 1956 | `Rev ${rev} should be known ${JSON.stringify(nodeToRevMap)} (bug in debugexportstack?)`, |
| b69ab31 | | | 1957 | ); |
| b69ab31 | | | 1958 | } |
| b69ab31 | | | 1959 | return rev; |
| b69ab31 | | | 1960 | }; |
| b69ab31 | | | 1961 | |
| b69ab31 | | | 1962 | // Calculate requested stack. |
| b69ab31 | | | 1963 | const commitStates = stack.map(commit => |
| b69ab31 | | | 1964 | CommitState({ |
| b69ab31 | | | 1965 | originalNodes: ImSet([commit.node]), |
| b69ab31 | | | 1966 | rev: nodeToRev(commit.node), |
| b69ab31 | | | 1967 | key: commit.node, |
| b69ab31 | | | 1968 | author: commit.author, |
| b69ab31 | | | 1969 | date: DateTuple({unix: commit.date[0], tz: commit.date[1]}), |
| b69ab31 | | | 1970 | text: commit.text, |
| b69ab31 | | | 1971 | // Treat commits that are not requested explicitly as immutable too. |
| b69ab31 | | | 1972 | immutableKind: commit.immutable || !commit.requested ? 'hash' : 'none', |
| b69ab31 | | | 1973 | parents: List((commit.parents ?? []).map(p => nodeToRev(p))), |
| b69ab31 | | | 1974 | files: ImMap<RepoPath, FileState>( |
| b69ab31 | | | 1975 | Object.entries(commit.files ?? {}).map(([path, file]) => [ |
| b69ab31 | | | 1976 | path, |
| b69ab31 | | | 1977 | convertExportFileToFileState(file), |
| b69ab31 | | | 1978 | ]), |
| b69ab31 | | | 1979 | ), |
| b69ab31 | | | 1980 | }), |
| b69ab31 | | | 1981 | ); |
| b69ab31 | | | 1982 | |
| b69ab31 | | | 1983 | return List(commitStates); |
| b69ab31 | | | 1984 | } |
| b69ab31 | | | 1985 | |
| b69ab31 | | | 1986 | /** Check that there is only one root in the stack. */ |
| b69ab31 | | | 1987 | function checkStackSingleRoot(stack: Readonly<ExportStack>) { |
| b69ab31 | | | 1988 | const rootNodes = stack.filter(commit => (commit.parents ?? []).length === 0); |
| b69ab31 | | | 1989 | if (rootNodes.length > 1) { |
| b69ab31 | | | 1990 | throw new Error( |
| b69ab31 | | | 1991 | `Multiple roots ${JSON.stringify(rootNodes.map(c => c.node))} is not supported`, |
| b69ab31 | | | 1992 | ); |
| b69ab31 | | | 1993 | } |
| b69ab31 | | | 1994 | } |
| b69ab31 | | | 1995 | |
| b69ab31 | | | 1996 | /** |
| b69ab31 | | | 1997 | * Check the exported stack and throws if it breaks assumptions. |
| b69ab31 | | | 1998 | * - No duplicated commits. |
| b69ab31 | | | 1999 | * - "parents" refer to other commits in the stack. |
| b69ab31 | | | 2000 | */ |
| b69ab31 | | | 2001 | function checkStackParents(stack: Readonly<ExportStack>) { |
| b69ab31 | | | 2002 | const knownNodes = new Set(); |
| b69ab31 | | | 2003 | stack.forEach(commit => { |
| b69ab31 | | | 2004 | const parents = commit.parents ?? []; |
| b69ab31 | | | 2005 | if (parents.length > 0) { |
| b69ab31 | | | 2006 | if (!commit.requested) { |
| b69ab31 | | | 2007 | throw new Error( |
| b69ab31 | | | 2008 | `Requested commit ${commit.node} should not have parents ${JSON.stringify( |
| b69ab31 | | | 2009 | parents, |
| b69ab31 | | | 2010 | )} (bug in debugexportstack?)`, |
| b69ab31 | | | 2011 | ); |
| b69ab31 | | | 2012 | } |
| b69ab31 | | | 2013 | parents.forEach(parentNode => { |
| b69ab31 | | | 2014 | if (!knownNodes.has(parentNode)) { |
| b69ab31 | | | 2015 | throw new Error(`Parent commit ${parentNode} is not exported (bug in debugexportstack?)`); |
| b69ab31 | | | 2016 | } |
| b69ab31 | | | 2017 | }); |
| b69ab31 | | | 2018 | } |
| b69ab31 | | | 2019 | if (parents.length > 1) { |
| b69ab31 | | | 2020 | throw new Error(`Merge commit ${commit.node} is not supported`); |
| b69ab31 | | | 2021 | } |
| b69ab31 | | | 2022 | knownNodes.add(commit.node); |
| b69ab31 | | | 2023 | }); |
| b69ab31 | | | 2024 | if (knownNodes.size != stack.length) { |
| b69ab31 | | | 2025 | throw new Error('Commit stack has duplicated nodes (bug in debugexportstack?)'); |
| b69ab31 | | | 2026 | } |
| b69ab31 | | | 2027 | } |
| b69ab31 | | | 2028 | |
| b69ab31 | | | 2029 | /** Rewrite fields that contains `rev` based on the mapping function. */ |
| b69ab31 | | | 2030 | function rewriteCommitRevs( |
| b69ab31 | | | 2031 | commit: CommitState, |
| b69ab31 | | | 2032 | revMapFunc: (rev: CommitRev) => CommitRev, |
| b69ab31 | | | 2033 | ): CommitState { |
| b69ab31 | | | 2034 | return commit.merge({ |
| b69ab31 | | | 2035 | rev: revMapFunc(commit.rev), |
| b69ab31 | | | 2036 | parents: commit.parents.map(revMapFunc), |
| b69ab31 | | | 2037 | }); |
| b69ab31 | | | 2038 | } |
| b69ab31 | | | 2039 | |
| b69ab31 | | | 2040 | /** Guess if commit message is meaningful. Messages like "wip" or "fixup" are meaningless. */ |
| b69ab31 | | | 2041 | function isMeaningfulText(text: string): boolean { |
| b69ab31 | | | 2042 | const trimmed = text.trim(); |
| b69ab31 | | | 2043 | return trimmed.includes(' ') || trimmed.includes('\n') || trimmed.length > 20; |
| b69ab31 | | | 2044 | } |
| b69ab31 | | | 2045 | |
| b69ab31 | | | 2046 | /** |
| b69ab31 | | | 2047 | * Turn distinct numbers to a 0..n sequence preserving the order. |
| b69ab31 | | | 2048 | * For example, turn [0, 100, 50] into [0, 2, 1]. |
| b69ab31 | | | 2049 | * This could convert CommitRevs to FileRevs, assuming the file |
| b69ab31 | | | 2050 | * stack is a sub-sequence of the commit sequence. |
| b69ab31 | | | 2051 | */ |
| b69ab31 | | | 2052 | function compactSequence(revs: CommitRev[]): FileRev[] { |
| b69ab31 | | | 2053 | const sortedRevs = [...revs].sort((aRev, bRev) => aRev - bRev); |
| b69ab31 | | | 2054 | return revs.map(rev => sortedRevs.indexOf(rev) as FileRev); |
| b69ab31 | | | 2055 | } |
| b69ab31 | | | 2056 | |
| b69ab31 | | | 2057 | /** Reorder rev and rev + 1. Return [] if rev is out of range */ |
| b69ab31 | | | 2058 | export function reorderedRevs(state: CommitStackState, rev: number): CommitRev[] { |
| b69ab31 | | | 2059 | // Basically, `toSpliced`, but it's not available everywhere. |
| b69ab31 | | | 2060 | const order = state.revs(); |
| b69ab31 | | | 2061 | if (rev < 0 || rev >= order.length - 1) { |
| b69ab31 | | | 2062 | return []; |
| b69ab31 | | | 2063 | } |
| b69ab31 | | | 2064 | const rev1 = order[rev]; |
| b69ab31 | | | 2065 | const rev2 = order[rev + 1]; |
| b69ab31 | | | 2066 | order.splice(rev, 2, rev2, rev1); |
| b69ab31 | | | 2067 | return order; |
| b69ab31 | | | 2068 | } |
| b69ab31 | | | 2069 | |
| b69ab31 | | | 2070 | type BuildFileStackOptions = {followRenames?: boolean}; |
| b69ab31 | | | 2071 | |
| b69ab31 | | | 2072 | // Re-export for compatibility |
| b69ab31 | | | 2073 | export { |
| b69ab31 | | | 2074 | ABSENT_FILE, |
| b69ab31 | | | 2075 | ABSENT_FLAG, |
| b69ab31 | | | 2076 | Base85, |
| b69ab31 | | | 2077 | CommitIdx, |
| b69ab31 | | | 2078 | CommitState, |
| b69ab31 | | | 2079 | DataRef, |
| b69ab31 | | | 2080 | DateTuple, |
| b69ab31 | | | 2081 | FileIdx, |
| b69ab31 | | | 2082 | FileState, |
| b69ab31 | | | 2083 | isAbsent, |
| b69ab31 | | | 2084 | isContentSame, |
| b69ab31 | | | 2085 | isRename, |
| b69ab31 | | | 2086 | isUtf8, |
| b69ab31 | | | 2087 | toMetadata, |
| b69ab31 | | | 2088 | }; |
| b69ab31 | | | 2089 | export type {CommitRev, FileMetadata, FileRev, FileStackIndex}; |