repo-dive 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -42694,7 +42694,7 @@ var runWith = (command, config) => {
42694
42694
  };
42695
42695
  var package_default = {
42696
42696
  name: "repo-dive",
42697
- version: "0.13.0",
42697
+ version: "0.14.0",
42698
42698
  description: "Dive into a git repo's history: per-commit snapshots, an indexed metrics catalog and an interactive dashboard",
42699
42699
  keywords: [
42700
42700
  "git",
@@ -42956,10 +42956,11 @@ var languageOfExtension = (extension) => languageByExtension[extension] ?? exten
42956
42956
  /**
42957
42957
  * Whether a collector describes the *state of the tree* at a commit rather than
42958
42958
  * facts about the commit itself. Such snapshots are only meaningful on the
42959
- * first-parent chain: a commit that lives on a side branch — or that arrived
42960
- * with a foreign history absorbed by an unrelated-histories merge — carries a
42961
- * tree that was never the repository's state, so charting it puts a cliff into
42962
- * every timeline.
42959
+ * mainline (the first-parent chain, extended backwards across founding
42960
+ * grafts — see `listMainlineShas`): a commit that lives on a side branch — or
42961
+ * that arrived with a foreign history absorbed mid-life by an
42962
+ * unrelated-histories merge — carries a tree that was never the repository's
42963
+ * state, so charting it puts a cliff into every timeline.
42963
42964
  */
42964
42965
  var describesTreeState = (collector) => collector.strategy !== "log";
42965
42966
  /** File extension used as a category key, e.g. ".ts"; files without one map to "(none)". */
@@ -50809,18 +50810,104 @@ var listCommits = (repoRoot) => runGit([
50809
50810
  "log",
50810
50811
  `--format=${gitLogFormat}`
50811
50812
  ]).pipe(succeedIfNoCommitsYet, map$7(parseGitLog));
50813
+ var chainLogFormat = [
50814
+ "%H",
50815
+ "%P",
50816
+ "%cI"
50817
+ ].join("%x1f");
50812
50818
  /**
50813
- * Shas on HEAD's first-parent chain, i.e. the states the repository actually
50814
- * passed through. See {@link describesTreeState} for why snapshot collectors
50815
- * are restricted to them.
50819
+ * The first-parent chain of `tip` (HEAD when omitted), newest first. HEAD is
50820
+ * left implicit rather than passed: an explicit rev makes git fail an empty
50821
+ * repository with "unknown revision" instead of the "does not have any commits
50822
+ * yet" that {@link succeedIfNoCommitsYet} recovers from.
50816
50823
  */
50817
- var listFirstParentShas = (repoRoot) => runGit([
50824
+ var listFirstParentChain = (repoRoot, tip) => runGit([
50818
50825
  "-C",
50819
50826
  repoRoot,
50820
50827
  "log",
50821
50828
  "--first-parent",
50822
- "--format=%H"
50823
- ]).pipe(succeedIfNoCommitsYet, map$7((stdout) => new Set(stdout.split("\n").filter(Boolean))));
50829
+ `--format=${chainLogFormat}`,
50830
+ ...tip === void 0 ? [] : [tip]
50831
+ ]).pipe(succeedIfNoCommitsYet, map$7((stdout) => stdout.split("\n").filter(Boolean).map((line) => {
50832
+ const [hash = "", parents = "", committerDate = ""] = line.split(fieldSeparator);
50833
+ return {
50834
+ hash,
50835
+ parentHashes: parents.split(" ").filter(Boolean),
50836
+ committerDate
50837
+ };
50838
+ })));
50839
+ /** No common ancestor means the two commits come from unrelated histories. */
50840
+ var haveUnrelatedHistories = (repoRoot, left, right) => runGit([
50841
+ "-C",
50842
+ repoRoot,
50843
+ "merge-base",
50844
+ left,
50845
+ right
50846
+ ], { okExitCodes: [1] }).pipe(map$7((stdout) => stdout.trim() === ""));
50847
+ /**
50848
+ * The first-parent chain that continues `chain` backwards in time across a
50849
+ * founding graft, or `[]` when there is none.
50850
+ *
50851
+ * A repository migration (monorepo assembly, host move, history rewrite)
50852
+ * leaves a recognizable signature: a fresh root commit followed immediately by
50853
+ * merges that absorb the project's previous histories. Both conditions below
50854
+ * are required, so ordinary absorptions stay excluded:
50855
+ *
50856
+ * - the merge sits in the founding window — the unbroken run of merges
50857
+ * directly above the root, before the first ordinary commit. A foreign
50858
+ * history vendored later in the repository's life does not qualify.
50859
+ * - the absorbed history ends before the root begins, so it occupies the
50860
+ * stretch of timeline where the current chain has nothing to say. A side
50861
+ * history that overlaps the chain (e.g. a plugin repository absorbed while
50862
+ * mainline development continued) does not qualify.
50863
+ *
50864
+ * When several absorbed histories qualify (effect's monorepo assembly merged
50865
+ * eight), the timeline can only continue into one of them: the one reaching
50866
+ * back furthest wins.
50867
+ */
50868
+ var findFoundingGraftChain = (repoRoot, chain, alreadyOnMainline) => gen(function* () {
50869
+ const root = chain.at(-1);
50870
+ if (root === void 0) return [];
50871
+ const rootDate = Date.parse(root.committerDate);
50872
+ const candidateTips = [];
50873
+ for (let index = chain.length - 2; index >= 0; index -= 1) {
50874
+ const entry = chain[index];
50875
+ if (entry === void 0 || entry.parentHashes.length < 2) break;
50876
+ candidateTips.push(...entry.parentHashes.slice(1));
50877
+ }
50878
+ let best = [];
50879
+ let bestRootDate = Number.POSITIVE_INFINITY;
50880
+ for (const tip of candidateTips) {
50881
+ if (alreadyOnMainline.has(tip)) continue;
50882
+ if (!(yield* haveUnrelatedHistories(repoRoot, root.hash, tip))) continue;
50883
+ const candidate = yield* listFirstParentChain(repoRoot, tip);
50884
+ const tipDate = Date.parse(candidate.at(0)?.committerDate ?? "");
50885
+ const candidateRootDate = Date.parse(candidate.at(-1)?.committerDate ?? "");
50886
+ if (Number.isNaN(tipDate) || Number.isNaN(candidateRootDate) || tipDate >= rootDate) continue;
50887
+ if (candidateRootDate < bestRootDate) {
50888
+ best = candidate;
50889
+ bestRootDate = candidateRootDate;
50890
+ }
50891
+ }
50892
+ return best;
50893
+ });
50894
+ /**
50895
+ * Shas on the mainline — the states the repository actually passed through.
50896
+ * That is HEAD's first-parent chain, extended backwards across founding grafts
50897
+ * (see {@link findFoundingGraftChain}): when a migration absorbed the
50898
+ * project's previous history behind a fresh root, the absorbed mainline is the
50899
+ * continuation of the timeline. See {@link describesTreeState} for why
50900
+ * snapshot collectors are restricted to the mainline.
50901
+ */
50902
+ var listMainlineShas = (repoRoot) => gen(function* () {
50903
+ const shas = /* @__PURE__ */ new Set();
50904
+ let chain = yield* listFirstParentChain(repoRoot);
50905
+ while (chain.length > 0) {
50906
+ for (const entry of chain) shas.add(entry.hash);
50907
+ chain = yield* findFoundingGraftChain(repoRoot, chain, shas);
50908
+ }
50909
+ return shas;
50910
+ });
50824
50911
  var summarizeCommits = (commits) => {
50825
50912
  const authorEmails = new Set(commits.map((commit) => commit.authorEmail));
50826
50913
  const dates = commits.flatMap((commit) => [commit.authorDate, commit.committerDate]).filter((date) => !Number.isNaN(Date.parse(date))).toSorted((left, right) => Date.parse(left) - Date.parse(right));
@@ -50900,10 +50987,10 @@ var runScan = ({ repoPath, collectorNames, maxCommits, sample, force = false })
50900
50987
  const summary = summarizeCommits(commits);
50901
50988
  const cacheKeys = new Map(collectors.map((collector) => [collector.name, collectorCacheKey(collector, config)]));
50902
50989
  const cacheKeyOf = (collector) => cacheKeys.get(collector.name) ?? collectorCacheKey(collector, config);
50903
- const firstParentShas = yield* listFirstParentShas(repoRoot);
50990
+ const mainlineShas = yield* listMainlineShas(repoRoot);
50904
50991
  const plans = collectors.map((collector) => {
50905
50992
  const policy = sampleOverride ?? collector.defaultSampling;
50906
- const candidates = describesTreeState(collector) ? selected.filter((commit) => firstParentShas.has(commit.hash)) : selected;
50993
+ const candidates = describesTreeState(collector) ? selected.filter((commit) => mainlineShas.has(commit.hash)) : selected;
50907
50994
  return {
50908
50995
  collector,
50909
50996
  policy,
@@ -51101,7 +51188,7 @@ var buildPlan = (repoRoot) => gen(function* () {
51101
51188
  "rev-list",
51102
51189
  "HEAD"
51103
51190
  ])).split("\n").filter(Boolean));
51104
- const firstParentShas = yield* listFirstParentShas(repoRoot);
51191
+ const mainlineShas = yield* listMainlineShas(repoRoot);
51105
51192
  const currentCacheKeys = new Map(builtInCollectors.map((collector) => [collector.name, collectorCacheKey(collector, config)]));
51106
51193
  const snapshotCollectorNames = new Set(builtInCollectors.filter((collector) => describesTreeState(collector)).map((collector) => collector.name));
51107
51194
  const unreachableShas = [];
@@ -51116,7 +51203,7 @@ var buildPlan = (repoRoot) => gen(function* () {
51116
51203
  const collectorNames = yield* readdirIfExists(path.join(commitsPath, sha));
51117
51204
  for (const collectorName of collectorNames) {
51118
51205
  countsByCollector.set(collectorName, (countsByCollector.get(collectorName) ?? 0) + 1);
51119
- if (!firstParentShas.has(sha) && snapshotCollectorNames.has(collectorName)) offMainlineOutputs.push({
51206
+ if (!mainlineShas.has(sha) && snapshotCollectorNames.has(collectorName)) offMainlineOutputs.push({
51120
51207
  sha,
51121
51208
  collectorName
51122
51209
  });
@@ -51737,7 +51824,7 @@ var runIndex = ({ repoPath }) => gen(function* () {
51737
51824
  const commitsPath = path.join(catalogPath, "commits");
51738
51825
  const registry = new Map(builtInCollectors.map((collector) => [collector.name, collector]));
51739
51826
  const gitCommits = yield* listCommits(repoRoot);
51740
- const firstParentShas = yield* listFirstParentShas(repoRoot);
51827
+ const mainlineShas = yield* listMainlineShas(repoRoot);
51741
51828
  const remoteUrl = yield* readRemoteUrl(repoRoot);
51742
51829
  const catalogShas = new Set(yield* tryPromise(async () => {
51743
51830
  try {
@@ -51750,7 +51837,7 @@ var runIndex = ({ repoPath }) => gen(function* () {
51750
51837
  if (orderedCommits.length === 0) return yield* new NoCollectedCommitsError({ commitsPath });
51751
51838
  const readOutcomes = yield* forEach$1(orderedCommits, (commit) => tryPromise(async () => {
51752
51839
  const commitDir = path.join(commitsPath, commit.hash);
51753
- const onMainline = firstParentShas.has(commit.hash);
51840
+ const onMainline = mainlineShas.has(commit.hash);
51754
51841
  const factsByCollector = /* @__PURE__ */ new Map();
51755
51842
  let unknownCollectorDirs = 0;
51756
51843
  let offMainlineSnapshots = 0;
@@ -56767,7 +56854,7 @@ var exists = (filePath) => promise(() => access(filePath).then(() => true, () =>
56767
56854
  var runStatus = ({ repoPath }) => gen(function* () {
56768
56855
  const repoRoot = yield* resolveRepoRoot(repoPath);
56769
56856
  const commits = yield* listCommits(repoRoot);
56770
- const firstParentShas = yield* listFirstParentShas(repoRoot);
56857
+ const mainlineShas = yield* listMainlineShas(repoRoot);
56771
56858
  const config = yield* loadConfig(repoRoot);
56772
56859
  const catalogPath = config.catalogPath;
56773
56860
  if (!(yield* exists(path.join(catalogPath, "catalog.json")))) {
@@ -56789,7 +56876,7 @@ var runStatus = ({ repoPath }) => gen(function* () {
56789
56876
  `Catalog: ${catalogPath}`
56790
56877
  ];
56791
56878
  for (const collector of builtInCollectors) {
56792
- const target = sampleCommits(describesTreeState(collector) ? commits.filter((commit) => firstParentShas.has(commit.hash)) : commits, collector.defaultSampling);
56879
+ const target = sampleCommits(describesTreeState(collector) ? commits.filter((commit) => mainlineShas.has(commit.hash)) : commits, collector.defaultSampling);
56793
56880
  const cacheKey = collectorCacheKey(collector, config);
56794
56881
  const collected = (yield* forEach$1(target, (commit) => isCollected(catalog, commit.hash, collector, cacheKey), { concurrency: 16 })).filter(Boolean).length;
56795
56882
  lines.push(collector.defaultSampling === "all" ? ` ${collector.name}: ${collected}/${target.length} commits collected` : ` ${collector.name}: ${collected}/${target.length} commits collected (${samplingLabel(collector.defaultSampling)} sample of ${commits.length})`);