@reviewflow/sdk 6.24.0 → 6.27.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.
@@ -0,0 +1,109 @@
1
+ import type { BranchDiffBase } from "../diff/types.ts";
2
+ import type { IsoDateTime } from "../types/ids.ts";
3
+ import type { CheckoutStatus } from "./sync.ts";
4
+
5
+ /** One working tree of a repository, as `git worktree list` names it. */
6
+ export interface CheckoutWorktree {
7
+ path: string;
8
+ /** the first one listed: the checkout the repository's `.git` lives in; none in a bare repository */
9
+ main: boolean;
10
+ /** null on a detached HEAD */
11
+ branch: string | null;
12
+ /** null on a branch with no commit yet */
13
+ headSha: string | null;
14
+ /** why it is locked, `""` when no reason was given; null when it is not */
15
+ lock: string | null;
16
+ /** its directory is gone: `git worktree prune` would drop it */
17
+ prunable: boolean;
18
+ /** null when prunable, or when git could not read it */
19
+ status: CheckoutStatus | null;
20
+ }
21
+
22
+ /** A local branch and where it stands. */
23
+ export interface CheckoutBranch {
24
+ name: string;
25
+ sha: string;
26
+ /** e.g. `origin/feat`; null when it tracks nothing */
27
+ upstream: string | null;
28
+ /** null without an upstream, or when it is gone */
29
+ ahead: number | null;
30
+ behind: number | null;
31
+ /** the upstream it tracks no longer exists, as after GitHub deleted a merged head */
32
+ upstreamGone: boolean;
33
+ /**
34
+ * Every commit of it is in the base, so deleting it loses nothing; never the
35
+ * base branch itself, which a local copy behind its remote would otherwise
36
+ * be. A squash or rebase merge leaves it unmerged: `upstreamGone` is the
37
+ * sign then.
38
+ */
39
+ merged: boolean;
40
+ /** the worktree it is checked out in; null when none */
41
+ worktreePath: string | null;
42
+ lastCommit: { subject: string; committedAt: IsoDateTime };
43
+ }
44
+
45
+ /** `GET /api/repos/branches?repo=<owner/repo>` — read from git alone, no network. */
46
+ export interface CheckoutBranchesResponse {
47
+ /** what `merged` is against; null when it cannot be found, no branch is then merged */
48
+ base: BranchDiffBase | null;
49
+ worktrees: CheckoutWorktree[];
50
+ /** most recently committed first */
51
+ branches: CheckoutBranch[];
52
+ }
53
+
54
+ /** An open pull request of a repository. */
55
+ export interface OpenPullRequest {
56
+ number: number;
57
+ title: string;
58
+ isDraft: boolean;
59
+ headRefName: string;
60
+ /** the head's repository, a fork's included; null when it was deleted */
61
+ headOwnerRepo: string | null;
62
+ /** null for a deleted account */
63
+ author: string | null;
64
+ updatedAt: IsoDateTime;
65
+ }
66
+
67
+ /** `GET /api/repos/pulls?repo=<owner/repo>` — the open pull requests, most recently updated first. */
68
+ export interface OpenPullRequestsResponse {
69
+ pullRequests: OpenPullRequest[];
70
+ /** every open pull request, the unlisted ones included */
71
+ totalCount: number;
72
+ }
73
+
74
+ /** A pull request whose head is a given branch, with the commit GitHub keeps for it. */
75
+ export interface BranchPullRequestHead {
76
+ number: number;
77
+ state: "CLOSED" | "MERGED" | "OPEN";
78
+ /** the head commit, kept by GitHub after the head branch is deleted */
79
+ sha: string;
80
+ }
81
+
82
+ const sameRepo = (left: string | null, right: string): boolean =>
83
+ left !== null && left.toLowerCase() === right.toLowerCase();
84
+
85
+ /** The open pull request whose head is this branch of this repository, not a fork's namesake. */
86
+ export const pullRequestOfBranch = (
87
+ pullRequests: OpenPullRequest[],
88
+ ownerRepo: string,
89
+ branch: string,
90
+ ): OpenPullRequest | null =>
91
+ pullRequests.find(
92
+ (pullRequest) =>
93
+ pullRequest.headRefName === branch &&
94
+ sameRepo(pullRequest.headOwnerRepo, ownerRepo),
95
+ ) ?? null;
96
+
97
+ /** The open pull requests no local branch is the head of: a fork's, or a branch never checked out. */
98
+ export const pullRequestsWithoutBranch = (
99
+ pullRequests: OpenPullRequest[],
100
+ ownerRepo: string,
101
+ branches: CheckoutBranch[],
102
+ ): OpenPullRequest[] => {
103
+ const names = new Set(branches.map(({ name }) => name));
104
+ return pullRequests.filter(
105
+ (pullRequest) =>
106
+ !names.has(pullRequest.headRefName) ||
107
+ !sameRepo(pullRequest.headOwnerRepo, ownerRepo),
108
+ );
109
+ };
@@ -0,0 +1,85 @@
1
+ import type { IsoDateTime } from "../types/ids.ts";
2
+
3
+ /** The commit a checkout has out, as its status line shows it. */
4
+ export interface CheckoutHeadCommit {
5
+ shortSha: string;
6
+ subject: string;
7
+ committedAt: IsoDateTime;
8
+ }
9
+
10
+ /**
11
+ * What git says about one checkout, read fresh on every request. Ahead and
12
+ * behind are against the remote-tracking ref as of the last fetch.
13
+ */
14
+ export interface CheckoutStatus {
15
+ path: string;
16
+ /** null on a detached HEAD */
17
+ branch: string | null;
18
+ /** null on a branch with no commit yet */
19
+ commit: CheckoutHeadCommit | null;
20
+ /** e.g. `origin/main`; null when the branch tracks nothing */
21
+ upstream: string | null;
22
+ /** null without an upstream, or when the upstream's ref no longer exists */
23
+ ahead: number | null;
24
+ behind: number | null;
25
+ modified: number;
26
+ untracked: number;
27
+ }
28
+
29
+ /** A checkout's status, for a surface offering Pull and Push on it. */
30
+ export interface CheckoutSyncResponse {
31
+ status: CheckoutStatus;
32
+ /** whether a branch with no upstream can be published to `origin` */
33
+ hasOrigin: boolean;
34
+ /** the fetch failed; the status is then as of the previous one */
35
+ fetchError: string | null;
36
+ }
37
+
38
+ /**
39
+ * An upstream git still names but whose ref the last fetch pruned — GitHub
40
+ * deletes a merged head branch. Pushing would bring a merged branch back, so it
41
+ * is refused rather than treated as "no upstream" and published again.
42
+ */
43
+ const upstreamGone = (upstream: string): string =>
44
+ `${upstream} no longer exists on the remote`;
45
+
46
+ /**
47
+ * Why a pull would be refused, or null when it is offered. A surface shows the
48
+ * button only on null and the server throws the same sentence before running
49
+ * git, so the two cannot disagree on what "applicable" means.
50
+ *
51
+ * `behind` is as of the last fetch: a checkout reading 0 may still have
52
+ * something to pull, which is why a surface fetches before reading.
53
+ */
54
+ export const pullRefusal = (status: CheckoutStatus): string | null => {
55
+ if (status.branch === null) return "a detached HEAD has nothing to pull into";
56
+ if (status.upstream === null) {
57
+ return `${status.branch} tracks no remote branch`;
58
+ }
59
+ if (status.behind === null) return upstreamGone(status.upstream);
60
+ if (status.behind === 0) {
61
+ return `${status.branch} is not behind ${status.upstream}`;
62
+ }
63
+ return null;
64
+ };
65
+
66
+ /**
67
+ * A branch with no upstream is published to `origin` rather than refused, so a
68
+ * new branch reaches GitHub from the same place. Diverged branches are offered
69
+ * too: git refuses the non-fast-forward itself, and its reason is what the
70
+ * user reads.
71
+ */
72
+ export const pushRefusal = (
73
+ status: CheckoutStatus,
74
+ hasOrigin: boolean,
75
+ ): string | null => {
76
+ if (status.branch === null) return "a detached HEAD has no branch to push";
77
+ if (status.upstream === null) {
78
+ return hasOrigin ? null : "this repository has no origin remote";
79
+ }
80
+ if (status.ahead === null) return upstreamGone(status.upstream);
81
+ if (status.ahead === 0) {
82
+ return `${status.branch} has nothing to push to ${status.upstream}`;
83
+ }
84
+ return null;
85
+ };
@@ -0,0 +1,136 @@
1
+ import type { BranchPullRequestHead } from "./overview.ts";
2
+
3
+ /**
4
+ * What removing a worktree would lose, and the refusals a surface and the
5
+ * server both ask before anything is deleted. Browser-safe: the reads and the
6
+ * removal itself are `@reviewflow/sdk/git`'s.
7
+ *
8
+ * **Refuse, never force.** An auto-remove that eats one uncommitted file loses
9
+ * trust for good, so nothing here passes `--force` or `-D`: a dirty worktree, a
10
+ * commit no remote holds, a lock or a process inside is refused with what to
11
+ * do, and git stays the last guard for a file written in between.
12
+ */
13
+
14
+ export interface WorktreeRemovalStatus {
15
+ /** the worktree, absolute */
16
+ path: string;
17
+ /** the main checkout holds the repository's `.git`: it is never removed */
18
+ main: boolean;
19
+ /** null on a detached HEAD: there is no branch to offer deleting */
20
+ branch: string | null;
21
+ /** tracked files with changes; `git worktree remove` refuses while either is non-zero */
22
+ modified: number;
23
+ untracked: number;
24
+ /**
25
+ * Commits on HEAD that exist only in this clone: reachable from no
26
+ * remote-tracking ref and from no kept sha (a pull request head, which GitHub
27
+ * keeps after the head branch is deleted). git has no guard of its own here.
28
+ */
29
+ unpushedCommits: number;
30
+ /** why git holds it locked, `""` without a reason; null when it is not locked */
31
+ lock: string | null;
32
+ /**
33
+ * Processes whose working directory is inside: a shell, an editor, an agent
34
+ * that would lose the ground under it. Null when it cannot be told (no
35
+ * `lsof`), which refuses nothing.
36
+ */
37
+ processesInside: number | null;
38
+ /**
39
+ * Ignored files, relative to the worktree, not symlinks, that the main
40
+ * checkout does not have: `git worktree remove` deletes them without asking
41
+ * and nothing else holds a copy. A warning, not a refusal: an ignored file is
42
+ * usually ignored because it is disposable.
43
+ */
44
+ localOnlyIgnored: string[];
45
+ }
46
+
47
+ const fileCount = (count: number, kind: string): string | null =>
48
+ count === 0 ? null : `${String(count)} ${kind} file${count === 1 ? "" : "s"}`;
49
+
50
+ export const mainWorktreeRefusal =
51
+ "This is the main checkout, which holds the repository: it is never removed.";
52
+
53
+ export const dirtyRefusal = ({
54
+ modified,
55
+ untracked,
56
+ }: Pick<WorktreeRemovalStatus, "modified" | "untracked">): string | null => {
57
+ const parts = [
58
+ fileCount(modified, "modified"),
59
+ fileCount(untracked, "untracked"),
60
+ ].filter((part) => part !== null);
61
+ if (parts.length === 0) return null;
62
+ return `${parts.join(" and ")}: git would refuse to remove this worktree. Commit or discard them first.`;
63
+ };
64
+
65
+ export const unpushedRefusal = (count: number): string | null => {
66
+ if (count === 0) return null;
67
+ const subject =
68
+ count === 1 ? "1 commit exists" : `${String(count)} commits exist`;
69
+ return `${subject} only in this clone, on no remote branch and in no pull request. Push them first.`;
70
+ };
71
+
72
+ export const lockRefusal = (lock: string | null): string | null => {
73
+ if (lock === null) return null;
74
+ const reason = lock === "" ? "" : ` (${lock})`;
75
+ return `git holds this worktree locked${reason}, usually by a Claude Code process still running in it. Exit it first.`;
76
+ };
77
+
78
+ export const processesRefusal = (count: number | null): string | null => {
79
+ if (count === null || count === 0) return null;
80
+ const subject =
81
+ count === 1 ? "1 process runs" : `${String(count)} processes run`;
82
+ return `${subject} inside this worktree (a shell, an editor or an agent): removing it would pull the directory out from under them. Exit them first.`;
83
+ };
84
+
85
+ /**
86
+ * Every reason the removal is refused, in the order to act on them; empty when
87
+ * it may go ahead. The server reads the status again and asks the same
88
+ * question before git touches the disk.
89
+ */
90
+ export const worktreeRemovalRefusals = (
91
+ status: WorktreeRemovalStatus,
92
+ ): string[] => {
93
+ if (status.main) return [mainWorktreeRefusal];
94
+ return [
95
+ dirtyRefusal(status),
96
+ unpushedRefusal(status.unpushedCommits),
97
+ lockRefusal(status.lock),
98
+ processesRefusal(status.processesInside),
99
+ ].filter((refusal) => refusal !== null);
100
+ };
101
+
102
+ /** The first refusal, or null when the removal may go ahead. */
103
+ export const worktreeRemovalRefusal = (
104
+ status: WorktreeRemovalStatus,
105
+ ): string | null => worktreeRemovalRefusals(status)[0] ?? null;
106
+
107
+ /** What a removal did: the worktree is gone; the branch deletion, asked for or not, may have been refused. */
108
+ export interface WorktreeRemovalResult {
109
+ path: string;
110
+ /** the branch `git branch -d` was run on; null when not asked, or on a detached HEAD */
111
+ branch: string | null;
112
+ /** git's refusal to delete the branch (unmerged, usually); the removal stands */
113
+ branchError: string | null;
114
+ }
115
+
116
+ /**
117
+ * `GET /api/repos/worktree-status?repo=<owner/repo>&worktree=<path>` — what
118
+ * removing the worktree would lose, its branch's pull request heads counted as
119
+ * pushed.
120
+ */
121
+ export interface WorktreeRemovalStatusResponse {
122
+ status: WorktreeRemovalStatus;
123
+ /** the pull requests of its branch; empty on a detached HEAD or when GitHub failed */
124
+ pullRequests: BranchPullRequestHead[];
125
+ /** why GitHub could not be read: its heads then prove nothing, the count errs toward refusing */
126
+ pullRequestsError: string | null;
127
+ }
128
+
129
+ /**
130
+ * `POST /api/repos/worktree-remove?repo=<owner/repo>&worktree=<path>` —
131
+ * answered once git has removed it, or with the first refusal as a 400.
132
+ */
133
+ export interface WorktreeRemovalRequest {
134
+ /** run `git branch -d` after the removal; a refusal is reported, never fatal */
135
+ deleteBranch: boolean;
136
+ }
@@ -0,0 +1,2 @@
1
+ /** The repositories folders searched when `config.json` sets neither `repoPaths` nor the legacy `defaultRepoPath`. */
2
+ export const defaultRepoPaths: readonly string[] = ["~/dev/"];
@@ -1,3 +1,9 @@
1
+ import {
2
+ isRecord,
3
+ parseConfigText,
4
+ stringifyConfig,
5
+ } from "./parseConfigText.ts";
6
+
1
7
  export interface InsertedCheckoutEntry {
2
8
  /** the config text with the entry in `repos` */
3
9
  source: string;
@@ -5,9 +11,6 @@ export interface InsertedCheckoutEntry {
5
11
  cursor: number;
6
12
  }
7
13
 
8
- const isRecord = (value: unknown): value is Record<string, unknown> =>
9
- typeof value === "object" && value !== null && !Array.isArray(value);
10
-
11
14
  /**
12
15
  * `"owner/repo": ""` added to `repos` of the app's config text, so an editor
13
16
  * can open on it with the cursor where the checkout path is typed. Nothing is
@@ -21,17 +24,8 @@ export const insertCheckoutEntry = (
21
24
  source: string,
22
25
  ownerRepo: string,
23
26
  ): InsertedCheckoutEntry | null => {
24
- let parsed: unknown;
25
- if (source.trim() === "") {
26
- parsed = {};
27
- } else {
28
- try {
29
- parsed = JSON.parse(source);
30
- } catch {
31
- return null;
32
- }
33
- }
34
- if (!isRecord(parsed)) return null;
27
+ const parsed = parseConfigText(source);
28
+ if (parsed === null) return null;
35
29
  const repos = parsed.repos ?? {};
36
30
  if (!isRecord(repos)) return null;
37
31
 
@@ -41,7 +35,7 @@ export const insertCheckoutEntry = (
41
35
  );
42
36
  const key = existing ?? ownerRepo;
43
37
  const next = existing === undefined ? { ...repos, [key]: "" } : repos;
44
- const text = `${JSON.stringify({ ...parsed, repos: next }, null, 2)}\n`;
38
+ const text = stringifyConfig({ ...parsed, repos: next });
45
39
 
46
40
  // the value follows the key on the same line; the same key may appear
47
41
  // earlier under another top-level key
@@ -0,0 +1,23 @@
1
+ export const isRecord = (value: unknown): value is Record<string, unknown> =>
2
+ typeof value === "object" && value !== null && !Array.isArray(value);
3
+
4
+ /**
5
+ * The app's config text as an object, a blank text being an empty config.
6
+ * `null` when the text is not a json object: the user's own text is not
7
+ * guessed at.
8
+ */
9
+ export const parseConfigText = (
10
+ source: string,
11
+ ): Record<string, unknown> | null => {
12
+ if (source.trim() === "") return {};
13
+ let parsed: unknown;
14
+ try {
15
+ parsed = JSON.parse(source);
16
+ } catch {
17
+ return null;
18
+ }
19
+ return isRecord(parsed) ? parsed : null;
20
+ };
21
+
22
+ export const stringifyConfig = (config: Record<string, unknown>): string =>
23
+ `${JSON.stringify(config, null, 2)}\n`;
@@ -0,0 +1,44 @@
1
+ import { parseConfigText, stringifyConfig } from "./parseConfigText.ts";
2
+
3
+ const repoPathsKeys = new Set(["repoPaths", "defaultRepoPath"]);
4
+
5
+ const isStringArray = (value: unknown): value is string[] =>
6
+ Array.isArray(value) && value.every((item) => typeof item === "string");
7
+
8
+ /**
9
+ * The repositories folders as the config text sets them: `repoPaths`, or the
10
+ * legacy `defaultRepoPath` as a one-folder list. `undefined` when neither is
11
+ * set or the text is unreadable, so the built-in default applies.
12
+ */
13
+ export const readRepoPathsSetting = (source: string): string[] | undefined => {
14
+ const config = parseConfigText(source);
15
+ if (config === null) return undefined;
16
+ if (isStringArray(config.repoPaths)) return config.repoPaths;
17
+ if (typeof config.defaultRepoPath === "string") {
18
+ return [config.defaultRepoPath];
19
+ }
20
+ return undefined;
21
+ };
22
+
23
+ /**
24
+ * The config text with `repoPaths` set to `paths`, trimmed, without blanks or
25
+ * duplicates. It takes the place of the legacy `defaultRepoPath`, or goes
26
+ * first. An empty list is written as is: it searches no folder, unlike an
27
+ * unset key. Rewritten as two-space json, other keys kept. `null` when the
28
+ * text is not a json object.
29
+ */
30
+ export const writeRepoPathsSetting = (
31
+ source: string,
32
+ paths: readonly string[],
33
+ ): string | null => {
34
+ const config = parseConfigText(source);
35
+ if (config === null) return null;
36
+ const repoPaths = [
37
+ ...new Set(paths.map((path) => path.trim()).filter((path) => path !== "")),
38
+ ];
39
+ const entries = Object.entries(config);
40
+ const at = entries.findIndex(([key]) => repoPathsKeys.has(key));
41
+ const others = entries.filter(([key]) => !repoPathsKeys.has(key));
42
+ others.splice(Math.max(at, 0), 0, ["repoPaths", repoPaths]);
43
+ return stringifyConfig(Object.fromEntries(others));
44
+ };
@@ -381,9 +381,9 @@ const transitiveDependencies = (
381
381
  };
382
382
 
383
383
  /**
384
- * The root first, as `groupDiffPaths` has it; then a package before every
385
- * package it depends on, directly or through a package the change does not
386
- * touch. Alphabetical between two unrelated packages, and to break a cycle.
384
+ * A package before every package it depends on, directly or through a
385
+ * package the change does not touch. Alphabetical between two unrelated
386
+ * packages, and to break a cycle. The root last, as `groupDiffPaths` has it.
387
387
  */
388
388
  export const orderPackages = (
389
389
  names: readonly string[],
@@ -401,7 +401,7 @@ export const orderPackages = (
401
401
  (other) => other !== name && dependencies.get(other)!.has(name),
402
402
  );
403
403
 
404
- const ordered = names.includes("") ? [""] : [];
404
+ const ordered: string[] = [];
405
405
  while (remaining.size > 0) {
406
406
  const sorted = [...remaining].toSorted((left, right) =>
407
407
  left.localeCompare(right),
@@ -411,6 +411,7 @@ export const orderPackages = (
411
411
  ordered.push(next);
412
412
  remaining.delete(next);
413
413
  }
414
+ if (names.includes("")) ordered.push("");
414
415
  return ordered;
415
416
  };
416
417
 
@@ -5,6 +5,7 @@
5
5
  * same list the ui renders.
6
6
  */
7
7
  export const diffThemes = [
8
+ "reviewflow",
8
9
  "github",
9
10
  "github-high-contrast",
10
11
  "pierre",
@@ -28,13 +29,22 @@ export type DiffTheme = (typeof diffThemes)[number];
28
29
  export const isDiffTheme = (value: unknown): value is DiffTheme =>
29
30
  (diffThemes as readonly unknown[]).includes(value);
30
31
 
32
+ export const defaultDiffTheme: DiffTheme = "reviewflow";
33
+
31
34
  interface DiffThemeFamily {
32
35
  label: string;
33
- /** shiki theme names, the pierre ones resolved by `@pierre/diffs` itself */
36
+ /**
37
+ * shiki theme names, the pierre ones resolved by `@pierre/diffs` itself, the
38
+ * reviewflow ones registered by `@reviewflow/ui`
39
+ */
34
40
  themes: { light: string; dark: string };
35
41
  }
36
42
 
37
43
  const families: Record<DiffTheme, DiffThemeFamily> = {
44
+ reviewflow: {
45
+ label: "Reviewflow",
46
+ themes: { light: "reviewflow-light", dark: "reviewflow-dark" },
47
+ },
38
48
  github: {
39
49
  label: "GitHub",
40
50
  themes: { light: "github-light-default", dark: "github-dark-default" },
@@ -103,3 +113,21 @@ export const diffThemeOptions = diffThemes.map((theme) => ({
103
113
  value: theme,
104
114
  label: families[theme].label,
105
115
  }));
116
+
117
+ /**
118
+ * The diff's code font: `alouette` is the design system's mono (Chivo Mono),
119
+ * `pierre` the system mono stack `@pierre/diffs` ships with, `custom` a font
120
+ * the user names (a CSS `font-family`, stored apart).
121
+ */
122
+ export type DiffFont = "alouette" | "custom" | "pierre";
123
+
124
+ export const isDiffFont = (value: unknown): value is DiffFont =>
125
+ value === "alouette" || value === "pierre" || value === "custom";
126
+
127
+ export const defaultDiffFont: DiffFont = "alouette";
128
+
129
+ export const diffFontOptions: { value: DiffFont; label: string }[] = [
130
+ { value: "alouette", label: "Alouette (Chivo Mono)" },
131
+ { value: "pierre", label: "Pierre (system mono)" },
132
+ { value: "custom", label: "Custom" },
133
+ ];
package/src/diff/types.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { PullRequestCommit } from "../api/pullRequest.ts";
2
+
1
3
  /**
2
4
  * `show` is git's plain diff. `hide` is `git diff -w`, GitHub's "Hide
3
5
  * whitespace": a line whose only change is whitespace is not a change.
@@ -25,6 +27,116 @@ export interface DiffOmittedFile {
25
27
  reason: DiffOmittedReason;
26
28
  }
27
29
 
30
+ /**
31
+ * `committed`: the merge base to `HEAD`, what the branch's pull request shows.
32
+ * `uncommitted`: `HEAD` to the working tree, the staged and unstaged changes
33
+ * and the untracked files.
34
+ */
35
+ export type BranchDiffScope = "committed" | "uncommitted";
36
+
37
+ export const isBranchDiffScope = (value: unknown): value is BranchDiffScope =>
38
+ value === "committed" || value === "uncommitted";
39
+
40
+ /** What a checkout's branch is diffed against. */
41
+ export interface BranchDiffBase {
42
+ /** the branch name, e.g. the repository's default branch */
43
+ name: string;
44
+ /** `origin/<name>` when that remote-tracking ref exists, `<name>` otherwise */
45
+ ref: string;
46
+ sha: string;
47
+ }
48
+
49
+ /**
50
+ * What a checked-out branch changed since it left its base. Nothing is fetched
51
+ * first: the base is as fresh as the last fetch, which is why its ref and sha
52
+ * travel with the patch.
53
+ */
54
+ export interface CheckoutBranchDiffResponse {
55
+ scope: BranchDiffScope;
56
+ whitespace: DiffWhitespace;
57
+ /** null on a detached HEAD */
58
+ branch: string | null;
59
+ headSha: string;
60
+ /** null under `uncommitted`, which needs no base */
61
+ base: BranchDiffBase | null;
62
+ mergeBase: string | null;
63
+ /** a unified diff, `a/` and `b/` prefixes, the omitted files left out */
64
+ patch: string;
65
+ omitted: DiffOmittedFile[];
66
+ /** untracked files in `patch`; always 0 under `committed` */
67
+ untrackedCount: number;
68
+ /** every file in `patch`, once: the root's group first, then in patch order */
69
+ groups: DiffGroup[];
70
+ }
71
+
72
+ /** A checkout the app's config names, and the branch it has out. */
73
+ export interface CheckoutRepo {
74
+ ownerRepo: string;
75
+ path: string;
76
+ /** false: not in the config, found directly under one of its `repoPaths` */
77
+ configured: boolean;
78
+ /** null on a detached HEAD, or when `problem` is set */
79
+ branch: string | null;
80
+ /** why the path cannot be used (missing, not this repository); null when it can */
81
+ problem: string | null;
82
+ }
83
+
84
+ /**
85
+ * `GET /api/repos` (`?refresh=1` scans the folders again) — the configured
86
+ * checkouts and the ones found directly under `repoPaths`, by `owner/repo`.
87
+ */
88
+ export interface CheckoutReposResponse {
89
+ repos: CheckoutRepo[];
90
+ configPath: string;
91
+ /** expanded */
92
+ repoPaths: string[];
93
+ /** when the folders were scanned: the found checkouts are cached until a refresh or a change of `repoPaths` */
94
+ scannedAt: string;
95
+ }
96
+
97
+ /** A folder holding checkouts that `repoPaths` does not list yet. */
98
+ export interface RepoPathSuggestion {
99
+ /** as it would be written in `repoPaths`: `~/…` under the home directory */
100
+ path: string;
101
+ /** the checkouts of GitHub repositories directly under it */
102
+ checkouts: number;
103
+ }
104
+
105
+ /** `GET /api/config/repo-paths/suggestions` */
106
+ export interface RepoPathSuggestionsResponse {
107
+ suggestions: RepoPathSuggestion[];
108
+ }
109
+
110
+ /** A commit of a checkout, as git has it: the author is git's name, with no avatar. */
111
+ export interface CheckoutCommit extends PullRequestCommit {
112
+ /** since the branch left its base: what the branch's diff carries */
113
+ onBranch: boolean;
114
+ /** reachable from a remote-tracking ref; null when the checkout has no remote */
115
+ pushed: boolean | null;
116
+ }
117
+
118
+ /** `GET /api/repos/commits` — the last commits of HEAD, newest first. */
119
+ export interface CheckoutCommitsResponse {
120
+ /** null on a detached HEAD */
121
+ branch: string | null;
122
+ /** null when the base cannot be found: no commit is then marked on the branch */
123
+ base: BranchDiffBase | null;
124
+ /** every commit since the branch left its base, listed or not; null without a base */
125
+ onBranchCount: number | null;
126
+ commits: CheckoutCommit[];
127
+ }
128
+
129
+ /** `GET /api/repos/commit` — one commit against its first parent. */
130
+ export interface CommitDiffResponse {
131
+ sha: string;
132
+ /** null for a root commit, diffed against the empty tree */
133
+ parentSha: string | null;
134
+ whitespace: DiffWhitespace;
135
+ patch: string;
136
+ omitted: DiffOmittedFile[];
137
+ groups: DiffGroup[];
138
+ }
139
+
28
140
  /**
29
141
  * The files of one package: the nearest directory above them holding a
30
142
  * manifest (`package.json`, `pyproject.toml`, `Cargo.toml`, `go.mod`,