git-stack-cli
Advanced tools
| import * as React from "react"; | ||
| import * as Ink from "ink-cjs"; | ||
| import { DateTime } from "luxon"; | ||
| type Props = { | ||
| node: React.ReactNode; | ||
| }; | ||
| export function DebugOutput(props: Props) { | ||
| const { stdout } = Ink.useStdout(); | ||
| const available_width = stdout.columns; | ||
| const timestamp = DateTime.now().toFormat("yyyy-MM-dd HH:mm:ss.SSS"); | ||
| const content_width = available_width - timestamp.length - 2; | ||
| const content = (function () { | ||
| switch (typeof props.node) { | ||
| case "boolean": | ||
| case "number": | ||
| case "string": { | ||
| return <Ink.Text dimColor>{String(props.node)}</Ink.Text>; | ||
| } | ||
| default: | ||
| return props.node; | ||
| } | ||
| })(); | ||
| return ( | ||
| <Ink.Box flexDirection="column"> | ||
| <Ink.Box flexDirection="row" gap={1} width={available_width}> | ||
| <Ink.Box width={timestamp.length} flexDirection="column"> | ||
| <Ink.Text dimColor>{timestamp}</Ink.Text> | ||
| </Ink.Box> | ||
| <Ink.Box width={content_width}>{content}</Ink.Box> | ||
| </Ink.Box> | ||
| </Ink.Box> | ||
| ); | ||
| } |
+1
-1
| { | ||
| "name": "git-stack-cli", | ||
| "version": "2.9.3", | ||
| "version": "2.9.4", | ||
| "description": "", | ||
@@ -5,0 +5,0 @@ "author": "magus", |
@@ -7,2 +7,3 @@ import * as React from "react"; | ||
| import { Brackets } from "~/app/Brackets"; | ||
| import { FormatText } from "~/app/FormatText"; | ||
| import { Parens } from "~/app/Parens"; | ||
@@ -55,30 +56,32 @@ import { Store } from "~/app/Store"; | ||
| actions.output( | ||
| <Ink.Text> | ||
| <Ink.Text>{"Github "}</Ink.Text> | ||
| <Brackets>graphql</Brackets> | ||
| <Ink.Text>{" API rate limit "}</Ink.Text> | ||
| <Brackets> | ||
| <Ink.Text>{used}</Ink.Text> | ||
| <Ink.Text>/</Ink.Text> | ||
| <Ink.Text>{limit}</Ink.Text> | ||
| </Brackets> | ||
| <Ink.Text>{" will reset at "}</Ink.Text> | ||
| <Ink.Text bold color={colors.yellow}> | ||
| {reset_time} | ||
| </Ink.Text> | ||
| <Ink.Text> </Ink.Text> | ||
| <Parens> | ||
| <Ink.Text>{"in "}</Ink.Text> | ||
| <Ink.Text bold color={colors.yellow}> | ||
| {time_until} | ||
| </Ink.Text> | ||
| </Parens> | ||
| </Ink.Text>, | ||
| <FormatText | ||
| message="Github {graphql} API rate limit {ratio} will reset at {reset_time} {time_until}" | ||
| values={{ | ||
| graphql: <Brackets>graphql</Brackets>, | ||
| ratio: ( | ||
| <Brackets> | ||
| <FormatText message="{used}/{limit}" values={{ used, limit }} /> | ||
| </Brackets> | ||
| ), | ||
| reset_time: ( | ||
| <Ink.Text bold color={colors.yellow}> | ||
| {reset_time} | ||
| </Ink.Text> | ||
| ), | ||
| time_until: ( | ||
| <Parens> | ||
| <FormatText | ||
| message="in {time_until}" | ||
| values={{ | ||
| time_until: ( | ||
| <Ink.Text bold color={colors.yellow}> | ||
| {time_until} | ||
| </Ink.Text> | ||
| ), | ||
| }} | ||
| /> | ||
| </Parens> | ||
| ), | ||
| }} | ||
| />, | ||
| ); | ||
@@ -85,0 +88,0 @@ |
@@ -50,2 +50,7 @@ import * as React from "react"; | ||
| // ensure merge_base is updated | ||
| actions.set((state) => { | ||
| state.merge_base = merge_base; | ||
| }); | ||
| // immediately paint all commit to preserve selected commit ranges | ||
@@ -120,3 +125,3 @@ let commit_range = await CommitMetadata.range(commit_map); | ||
| state.step = "sync-github"; | ||
| state.sync_github = { commit_range, rebase_group_index }; | ||
| state.sync_github = { commit_range }; | ||
| }); | ||
@@ -123,0 +128,0 @@ } else { |
+8
-17
@@ -5,2 +5,3 @@ import * as React from "react"; | ||
| import { DebugOutput } from "~/app/DebugOutput"; | ||
| import { Store } from "~/app/Store"; | ||
@@ -11,3 +12,2 @@ | ||
| const pending_output = Store.useState((state) => state.pending_output); | ||
| const pending_output_items = Object.values(pending_output); | ||
@@ -17,21 +17,12 @@ return ( | ||
| <Ink.Static items={output}> | ||
| {(node, i) => { | ||
| return <Ink.Box key={i}>{node}</Ink.Box>; | ||
| {(entry) => { | ||
| const [id, node] = entry; | ||
| return <Ink.Box key={id}>{node}</Ink.Box>; | ||
| }} | ||
| </Ink.Static> | ||
| {pending_output_items.map((node_list, i) => { | ||
| return ( | ||
| <Ink.Box key={i}> | ||
| <Ink.Text> | ||
| {node_list.map((text, j) => { | ||
| return ( | ||
| <React.Fragment key={j}> | ||
| <Ink.Text>{text}</Ink.Text> | ||
| </React.Fragment> | ||
| ); | ||
| })} | ||
| </Ink.Text> | ||
| </Ink.Box> | ||
| ); | ||
| {Object.entries(pending_output).map((entry) => { | ||
| const [id, content_list] = entry; | ||
| const content = content_list.join(""); | ||
| return <DebugOutput key={id} node={content} />; | ||
| })} | ||
@@ -38,0 +29,0 @@ </React.Fragment> |
+30
-71
| import * as React from "react"; | ||
| import crypto from "node:crypto"; | ||
| import * as Ink from "ink-cjs"; | ||
@@ -7,4 +9,4 @@ import { createStore, useStore } from "zustand"; | ||
| import { DebugOutput } from "~/app/DebugOutput"; | ||
| import { Exit } from "~/app/Exit"; | ||
| import { LogTimestamp } from "~/app/LogTimestamp"; | ||
| import { colors } from "~/core/colors"; | ||
@@ -25,4 +27,2 @@ import { pretty_json } from "~/core/pretty_json"; | ||
| id?: string; | ||
| debug?: boolean; | ||
| withoutTimestamp?: boolean; | ||
| }; | ||
@@ -32,3 +32,2 @@ | ||
| commit_range: CommitMetadata.CommitRange; | ||
| rebase_group_index: number; | ||
| }; | ||
@@ -79,8 +78,8 @@ | ||
| output: Array<React.ReactNode>; | ||
| pending_output: Record<string, Array<React.ReactNode>>; | ||
| output: Array<[string, React.ReactNode]>; | ||
| pending_output: Record<string, Array<string>>; | ||
| // cache | ||
| pr: { [branch: string]: PullRequest }; | ||
| cache_pr_diff: { [id: number]: string }; | ||
| cache_gh_cli_by_branch: { [branch: string]: { [command: string]: string } }; | ||
@@ -95,3 +94,5 @@ actions: { | ||
| output(node: React.ReactNode): void; | ||
| debug(node: React.ReactNode, id?: string): void; | ||
| debug(node: React.ReactNode): void; | ||
| debug_pending(id: string, content: string): void; | ||
| debug_pending_end(id: string): void; | ||
@@ -108,4 +109,2 @@ isDebug(): boolean; | ||
| output(state: State, args: MutateOutputArgs): void; | ||
| pending_output(state: State, args: MutateOutputArgs): void; | ||
| end_pending_output(state: State, id: string): void; | ||
| }; | ||
@@ -148,3 +147,3 @@ | ||
| pr: {}, | ||
| cache_pr_diff: {}, | ||
| cache_gh_cli_by_branch: {}, | ||
@@ -221,12 +220,18 @@ actions: { | ||
| debug(node, id) { | ||
| debug(node) { | ||
| if (get().actions.isDebug()) { | ||
| const debug = true; | ||
| set((state) => { | ||
| state.mutate.output(state, { node: <DebugOutput node={node} /> }); | ||
| }); | ||
| } | ||
| }, | ||
| debug_pending(id, content) { | ||
| if (get().actions.isDebug()) { | ||
| set((state) => { | ||
| if (id) { | ||
| state.mutate.pending_output(state, { id, node, debug }); | ||
| } else { | ||
| state.mutate.output(state, { node, debug }); | ||
| if (!state.pending_output[id]) { | ||
| state.pending_output[id] = []; | ||
| } | ||
| state.pending_output[id].push(content); | ||
| }); | ||
@@ -236,2 +241,8 @@ } | ||
| debug_pending_end(id) { | ||
| set((state) => { | ||
| delete state.pending_output[id]; | ||
| }); | ||
| }, | ||
| isDebug() { | ||
@@ -263,35 +274,5 @@ const state = get(); | ||
| output(state, args) { | ||
| const renderOutput = renderOutputArgs(args); | ||
| state.output.push(renderOutput); | ||
| const id = crypto.randomUUID(); | ||
| state.output.push([id, args.node]); | ||
| }, | ||
| pending_output(state, args) { | ||
| const { id } = args; | ||
| if (!id) { | ||
| return; | ||
| } | ||
| // set `withoutTimestamp` to skip <LogTimestamp> for all subsequent pending outputs | ||
| // we only want to timestamp for the first part (when we initialize the []) | ||
| // if we have many incremental outputs on the same line we do not want multiple timestamps | ||
| // | ||
| // await Promise.all([ | ||
| // cli(`for i in $(seq 1 5); do echo $i; sleep 1; done`), | ||
| // cli(`for i in $(seq 5 1); do printf "$i "; sleep 1; done; echo`), | ||
| // ]); | ||
| // | ||
| let withoutTimestamp = true; | ||
| if (!state.pending_output[id]) { | ||
| withoutTimestamp = false; | ||
| state.pending_output[id] = []; | ||
| } | ||
| const renderOutput = renderOutputArgs({ ...args, withoutTimestamp }); | ||
| state.pending_output[id].push(renderOutput); | ||
| }, | ||
| end_pending_output(state, id) { | ||
| delete state.pending_output[id]; | ||
| }, | ||
| }, | ||
@@ -307,24 +288,2 @@ | ||
| function renderOutputArgs(args: MutateOutputArgs) { | ||
| let output = args.node; | ||
| switch (typeof args.node) { | ||
| case "boolean": | ||
| case "number": | ||
| case "string": | ||
| output = <Ink.Text dimColor={args.debug}>{String(args.node)}</Ink.Text>; | ||
| } | ||
| if (args.debug) { | ||
| return ( | ||
| <React.Fragment> | ||
| {args.withoutTimestamp ? null : <LogTimestamp />} | ||
| {output} | ||
| </React.Fragment> | ||
| ); | ||
| } | ||
| return output; | ||
| } | ||
| function useState<R>(selector: (state: State) => R): R { | ||
@@ -331,0 +290,0 @@ return useStore(BaseStore, selector); |
+31
-30
@@ -29,5 +29,6 @@ import * as React from "react"; | ||
| const branch_name = state.branch_name; | ||
| const merge_base = state.merge_base; | ||
| const commit_map = state.commit_map; | ||
| const master_branch = state.master_branch; | ||
| const repo_root = state.repo_root; | ||
| const repo_path = state.repo_path; | ||
| const sync_github = state.sync_github; | ||
@@ -37,7 +38,6 @@ | ||
| invariant(commit_map, "commit_map must exist"); | ||
| invariant(repo_root, "repo_root must exist"); | ||
| invariant(repo_path, "repo_path must exist"); | ||
| invariant(sync_github, "sync_github must exist"); | ||
| const commit_range = sync_github.commit_range; | ||
| const rebase_group_index = sync_github.rebase_group_index; | ||
@@ -51,5 +51,2 @@ let DEFAULT_PR_BODY = ""; | ||
| // console.debug({ push_group_list }); | ||
| // throw new Error("STOP"); | ||
| // for all push targets in push_group_list | ||
@@ -171,3 +168,3 @@ // things that can be done in parallel are grouped by numbers | ||
| delete state.pr[group.pr.headRefName]; | ||
| delete state.cache_pr_diff[group.pr.number]; | ||
| delete state.cache_gh_cli_by_branch[group.pr.headRefName]; | ||
| } | ||
@@ -199,20 +196,10 @@ } | ||
| function get_push_group_list() { | ||
| // start from HEAD and work backward to rebase_group_index | ||
| const push_group_list = []; | ||
| for (let i = 0; i < commit_range.group_list.length; i++) { | ||
| const index = commit_range.group_list.length - 1 - i; | ||
| // do not go past rebase_group_index | ||
| if (index < rebase_group_index) { | ||
| break; | ||
| } | ||
| const group = commit_range.group_list[index]; | ||
| for (let group of commit_range.group_list) { | ||
| // skip the unassigned commits group | ||
| if (group.id === commit_range.UNASSIGNED) continue; | ||
| // if not --force, skip non-dirty master_base groups | ||
| if (group.master_base && !group.dirty && !argv.force) continue; | ||
| // if not --force, skip non-dirty groups | ||
| if (!group.dirty && !argv.force) continue; | ||
@@ -248,3 +235,3 @@ push_group_list.unshift(group); | ||
| // | ||
| if (!is_master_base(group)) { | ||
| if (!is_pr_master_base(group)) { | ||
| await github.pr_edit({ | ||
@@ -267,3 +254,11 @@ branch: group.id, | ||
| if (group.pr) { | ||
| if (!is_master_base(group)) { | ||
| // there are two scenarios where we should restore the base after push | ||
| // 1. if we aren't master base and pr is master base we should fix it | ||
| const base_mismatch = !group.master_base && is_pr_master_base(group); | ||
| // 2. if group pr was not master before the push we set it to master before pushing | ||
| // now we need to restore it back to how it was before the before_push | ||
| const was_modified_before_push = !is_pr_master_base(group); | ||
| const needs_base_fix = base_mismatch || was_modified_before_push; | ||
| if (needs_base_fix) { | ||
| // ensure base matches pr in github | ||
@@ -323,3 +318,3 @@ await github.pr_edit({ branch: group.id, base: group.base }); | ||
| function is_master_base(group: CommitMetadataGroup) { | ||
| function is_pr_master_base(group: CommitMetadataGroup) { | ||
| if (!group.pr) { | ||
@@ -329,10 +324,16 @@ return false; | ||
| return group.master_base || `origin/${group.pr.baseRefName}` === master_branch; | ||
| return `origin/${group.pr.baseRefName}` === master_branch; | ||
| } | ||
| async function push_master_group(group: CommitMetadataGroup) { | ||
| invariant(repo_root, "repo_root must exist"); | ||
| invariant(repo_path, "repo_path must exist"); | ||
| const repo_rel_worktree_path = `.git/git-stack-worktrees/push_master_group`; | ||
| const worktree_path = path.join(repo_root, repo_rel_worktree_path); | ||
| const worktree_path = path.join( | ||
| process.env.HOME, | ||
| ".cache", | ||
| "git-stack", | ||
| "worktrees", | ||
| repo_path, | ||
| "push_master_group", | ||
| ); | ||
@@ -343,3 +344,3 @@ // ensure worktree for pushing master groups | ||
| <Ink.Text color={colors.white}> | ||
| Creating <Ink.Text color={colors.yellow}>{repo_rel_worktree_path}</Ink.Text> | ||
| Creating <Ink.Text color={colors.yellow}>{worktree_path}</Ink.Text> | ||
| </Ink.Text>, | ||
@@ -355,3 +356,3 @@ ); | ||
| // - abort any in-progress cherry-pick/rebase | ||
| // - drop local changes/untracked files (including ignored) for a truly fresh state | ||
| // - drop local changes/untracked files to fresh state | ||
| // - reset to the desired base | ||
@@ -362,3 +363,3 @@ await cli(`git -C ${worktree_path} cherry-pick --abort`, { ignoreExitCode: true }); | ||
| await cli(`git -C ${worktree_path} checkout -f ${master_branch}`); | ||
| await cli(`git -C ${worktree_path} reset --hard ${master_branch}`); | ||
| await cli(`git -C ${worktree_path} reset --hard ${merge_base}`); | ||
| await cli(`git -C ${worktree_path} clean -fd`); | ||
@@ -365,0 +366,0 @@ |
| import * as React from "react"; | ||
| import path from "node:path"; | ||
| import * as Ink from "ink-cjs"; | ||
@@ -9,2 +11,3 @@ | ||
| import { colors } from "~/core/colors"; | ||
| import { pretty_json } from "~/core/pretty_json"; | ||
@@ -33,2 +36,7 @@ type Props = { | ||
| await cli(`echo GIT_AUTHOR_EMAIL=$GIT_AUTHOR_EMAIL`); | ||
| const PATH = process.env["PATH"]; | ||
| const PATH_LIST = pretty_json(PATH.split(path.delimiter)); | ||
| actions.debug(`process.env.PATH ${PATH_LIST}`); | ||
| await cli(`git config --list --show-origin`); | ||
@@ -35,0 +43,0 @@ } catch (err) { |
+12
-5
@@ -11,2 +11,3 @@ import * as child from "node:child_process"; | ||
| onOutput?: (data: string) => void; | ||
| quiet?: boolean; | ||
| }; | ||
@@ -49,3 +50,3 @@ | ||
| state.actions.debug(log.start(command)); | ||
| state.actions.debug(log.pending(command), id); | ||
| state.actions.debug_pending(id, log.pending(command)); | ||
@@ -56,3 +57,5 @@ const timer = Timer(); | ||
| output += value; | ||
| state.actions.debug(value, id); | ||
| if (!options.quiet) { | ||
| state.actions.debug_pending(id, value); | ||
| } | ||
| options.onOutput?.(value); | ||
@@ -85,5 +88,7 @@ } | ||
| state.actions.set((state) => state.mutate.end_pending_output(state, id)); | ||
| state.actions.debug_pending_end(id); | ||
| state.actions.debug(log.end(result)); | ||
| state.actions.debug(log.output(result)); | ||
| if (!options.quiet) { | ||
| state.actions.debug(log.output(result)); | ||
| } | ||
@@ -140,3 +145,5 @@ if (!options.ignoreExitCode && result.code !== 0) { | ||
| state.actions.debug(log.end(result)); | ||
| state.actions.debug(log.output(result)); | ||
| if (!options.quiet) { | ||
| state.actions.debug(log.output(result)); | ||
| } | ||
@@ -143,0 +150,0 @@ if (!options.ignoreExitCode && result.code !== 0) { |
@@ -6,2 +6,3 @@ /* eslint-disable no-console */ | ||
| import * as github from "~/core/github"; | ||
| import { invariant } from "~/core/invariant"; | ||
@@ -32,6 +33,6 @@ export type CommitRange = Awaited<ReturnType<typeof range>>; | ||
| export async function range(commit_group_map?: CommitGroupMap) { | ||
| const DEBUG = process.env.DEV && false; | ||
| const state = Store.getState(); | ||
| const actions = state.actions; | ||
| const argv = state.argv; | ||
| const merge_base = state.merge_base; | ||
| const master_branch = state.master_branch; | ||
@@ -41,2 +42,4 @@ const master_branch_name = master_branch.replace(/^origin\//, ""); | ||
| invariant(merge_base, "merge_base must exist"); | ||
| const pr_lookup: Record<string, void | PullRequest> = {}; | ||
@@ -131,2 +134,11 @@ | ||
| // actions.json({ group }); | ||
| actions.debug(`title=${group.title}`); | ||
| actions.debug(` id=${group.id}`); | ||
| actions.debug(` master_base=${group.master_base}`); | ||
| // special case | ||
| // boundary between normal commits and master commits | ||
| const MASTER_BASE_BOUNDARY = !group.master_base && previous_group && previous_group.master_base; | ||
| if (group.id !== UNASSIGNED) { | ||
@@ -152,5 +164,4 @@ let pr_result = pr_lookup[group.id]; | ||
| } else { | ||
| const last_group = group_value_list[i - 1]; | ||
| // console.debug(" ", "last_group", last_group.pr?.title.substring(0, 40)); | ||
| // console.debug(" ", "last_group.id", last_group.id); | ||
| // console.debug(" ", "previous_group", previous_group.pr?.title.substring(0, 40)); | ||
| // console.debug(" ", "previous_group.id", previous_group.id); | ||
@@ -163,69 +174,89 @@ if (group.master_base) { | ||
| group.base = null; | ||
| } else if (last_group.base === null) { | ||
| } else if (MASTER_BASE_BOUNDARY) { | ||
| // ensure we set its base to `master` | ||
| actions.debug(` MASTER_BASE_BOUNDARY set group.base = ${master_branch_name}`); | ||
| group.base = master_branch_name; | ||
| } else if (previous_group.base === null) { | ||
| // null out base when last group base is null | ||
| group.base = null; | ||
| } else { | ||
| group.base = last_group.id; | ||
| group.base = previous_group.id; | ||
| } | ||
| // console.debug(" ", "group.base", group.base); | ||
| } | ||
| actions.json({ group }); | ||
| actions.debug(` base=${group.base}`); | ||
| if (!group.pr) { | ||
| actions.debug(` group.pr=${group.pr}`); | ||
| group.dirty = true; | ||
| } else { | ||
| if (group.pr.baseRefName !== group.base) { | ||
| actions.debug("PR_BASEREF_MISMATCH"); | ||
| // actions.json(group.pr); | ||
| actions.debug(` group.pr.state=${group.pr.state}`); | ||
| actions.debug(` group.pr.baseRefName=${group.pr.baseRefName}`); | ||
| if (group.pr.state === "MERGED" || group.pr.state === "CLOSED") { | ||
| group.dirty = true; | ||
| } else if (group.pr.baseRefName !== group.base) { | ||
| actions.debug(" PR_BASEREF_MISMATCH"); | ||
| group.dirty = true; | ||
| } else if (group.master_base) { | ||
| actions.debug("MASTER_BASE_DIFF_COMPARE"); | ||
| // first check if merge base has changed | ||
| let branch_compare = await github.pr_compare(group.pr.headRefName); | ||
| if (!(branch_compare instanceof Error)) { | ||
| if (branch_compare.merge_base_commit.sha !== merge_base) { | ||
| actions.debug(" MASTER_BASE_MERGE_BASE_MISMATCH"); | ||
| group.dirty = true; | ||
| } | ||
| } | ||
| // special case | ||
| // master_base groups cannot be compared by commit sha | ||
| // instead compare the literal diff local against origin | ||
| // gh pr diff --color=never 110 | ||
| // git --no-pager diff --color=never 00c8fe0~1..00c8fe0 | ||
| let diff_github = await github.pr_diff(group.pr.number); | ||
| diff_github = normalize_diff(diff_github); | ||
| // if still not dirty, check diffs | ||
| if (!group.dirty) { | ||
| actions.debug(" MASTER_BASE_DIFF_COMPARE"); | ||
| let diff_local = await git.get_diff(group.commits); | ||
| diff_local = normalize_diff(diff_local); | ||
| // special case | ||
| // master_base groups cannot be compared by commit sha | ||
| // instead compare the literal diff local against origin | ||
| // gh pr diff --color=never 110 | ||
| // git --no-pager diff --color=never 00c8fe0~1..00c8fe0 | ||
| let diff_github = await github.pr_diff(group.pr.headRefName); | ||
| diff_github = normalize_diff(diff_github); | ||
| actions.json({ diff_local, diff_github }); | ||
| let diff_local = await git.get_diff(group.commits); | ||
| diff_local = normalize_diff(diff_local); | ||
| // find the first differing character index | ||
| let compare_length = Math.max(diff_github.length, diff_local.length); | ||
| let diff_index = -1; | ||
| for (let c_i = 0; c_i < compare_length; c_i++) { | ||
| if (diff_github[c_i] !== diff_local[c_i]) { | ||
| diff_index = c_i; | ||
| break; | ||
| // find the first differing character index | ||
| let compare_length = Math.max(diff_github.length, diff_local.length); | ||
| let diff_index = -1; | ||
| for (let c_i = 0; c_i < compare_length; c_i++) { | ||
| if (diff_github[c_i] !== diff_local[c_i]) { | ||
| diff_index = c_i; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| if (diff_index > -1) { | ||
| group.dirty = true; | ||
| if (diff_index > -1) { | ||
| actions.debug(" MASTER_BASE_DIFF_MISMATCH"); | ||
| group.dirty = true; | ||
| if (DEBUG) { | ||
| // print preview at diff_index for both strings | ||
| const preview_radius = 30; | ||
| const start_index = Math.max(0, diff_index - preview_radius); | ||
| const end_index = Math.min(compare_length, diff_index + preview_radius); | ||
| if (argv.verbose) { | ||
| // print preview at diff_index for both strings | ||
| const preview_radius = 30; | ||
| const start_index = Math.max(0, diff_index - preview_radius); | ||
| const end_index = Math.min(compare_length, diff_index + preview_radius); | ||
| diff_github = diff_github.substring(start_index, end_index); | ||
| diff_github = JSON.stringify(diff_github).slice(1, -1); | ||
| diff_github = diff_github.substring(start_index, end_index); | ||
| diff_github = JSON.stringify(diff_github).slice(1, -1); | ||
| diff_local = diff_local.substring(start_index, end_index); | ||
| diff_local = JSON.stringify(diff_local).slice(1, -1); | ||
| diff_local = diff_local.substring(start_index, end_index); | ||
| diff_local = JSON.stringify(diff_local).slice(1, -1); | ||
| let pointer_indent = " ".repeat(diff_index - start_index + 1); | ||
| actions.debug(`⚠️ git diff mismatch`); | ||
| actions.debug(` ${pointer_indent}⌄`); | ||
| actions.debug(`diff_github …${diff_github}…`); | ||
| actions.debug(`diff_local …${diff_local}…`); | ||
| actions.debug(` ${pointer_indent}⌃`); | ||
| let pointer_indent = " ".repeat(diff_index - start_index + 1); | ||
| actions.debug(` ⚠️ git diff mismatch`); | ||
| actions.debug(` ${pointer_indent}⌄`); | ||
| actions.debug(` diff_github …${diff_github}…`); | ||
| actions.debug(` diff_local …${diff_local}…`); | ||
| actions.debug(` ${pointer_indent}⌃`); | ||
| } | ||
| } | ||
| } | ||
| } else if (!group.master_base && previous_group && previous_group.master_base) { | ||
| } else if (MASTER_BASE_BOUNDARY) { | ||
| // special case | ||
@@ -248,6 +279,5 @@ // boundary between normal commits and master commits | ||
| if (group.pr.commits.length !== all_commits.length) { | ||
| actions.debug("BOUNDARY_COMMIT_LENGTH_MISMATCH"); | ||
| actions.debug(" BOUNDARY_COMMIT_LENGTH_MISMATCH"); | ||
| group.dirty = true; | ||
| } else { | ||
| actions.debug("BOUNDARY_COMMIT_SHA_COMPARISON"); | ||
| for (let i = 0; i < group.pr.commits.length; i++) { | ||
@@ -258,2 +288,3 @@ const pr_commit = group.pr.commits[i]; | ||
| if (pr_commit.oid !== local_commit.sha) { | ||
| actions.debug(" BOUNDARY_COMMIT_SHA_MISMATCH"); | ||
| group.dirty = true; | ||
@@ -264,6 +295,5 @@ } | ||
| } else if (group.pr.commits.length !== group.commits.length) { | ||
| actions.debug("COMMIT_LENGTH_MISMATCH"); | ||
| actions.debug(" COMMIT_LENGTH_MISMATCH"); | ||
| group.dirty = true; | ||
| } else { | ||
| actions.debug("COMMIT_SHA_COMPARISON"); | ||
| // if we still haven't marked this dirty, check each commit | ||
@@ -276,3 +306,3 @@ // comapre literal commit shas in group | ||
| if (pr_commit.oid !== local_commit.sha) { | ||
| actions.json({ pr_commit, local_commit }); | ||
| actions.debug(" COMMIT_SHA_MISMATCH"); | ||
| group.dirty = true; | ||
@@ -284,3 +314,3 @@ } | ||
| // console.debug(" ", "group.dirty", group.dirty); | ||
| actions.debug(` group.dirty=${group.dirty}`); | ||
| } | ||
@@ -287,0 +317,0 @@ |
+1
-1
@@ -64,3 +64,3 @@ import { Store } from "~/app/Store"; | ||
| const sha_range = `${first_commit.sha}~1..${last_commit.sha}`; | ||
| const diff_result = await cli(`git --no-pager diff --color=never ${sha_range}`); | ||
| const diff_result = await cli(`git --no-pager diff --color=never ${sha_range}`, { quiet: true }); | ||
| return diff_result.stdout; | ||
@@ -67,0 +67,0 @@ } |
+135
-46
@@ -43,3 +43,2 @@ import * as React from "react"; | ||
| <FormatText | ||
| wrapper={<Ink.Text dimColor />} | ||
| message="Github cache {count} open PRs from {repo_path} authored by {username}" | ||
@@ -105,3 +104,4 @@ values={{ | ||
| const pr = await gh_json<PullRequest>(`pr view ${branch} --repo ${repo_path} ${JSON_FIELDS}`); | ||
| const commmand = `pr view ${branch} --repo ${repo_path} ${JSON_FIELDS}`; | ||
| const pr = await gh_json<PullRequest>(commmand, { branch }); | ||
@@ -246,44 +246,33 @@ if (pr instanceof Error) { | ||
| export async function pr_diff(number: number) { | ||
| const state = Store.getState(); | ||
| const actions = state.actions; | ||
| export async function pr_diff(branch: string) { | ||
| // https://cli.github.com/manual/gh_pr_diff | ||
| const result = await gh(`pr diff --color=never ${branch}`, { branch }); | ||
| const maybe_diff = state.cache_pr_diff[number]; | ||
| if (result instanceof Error) { | ||
| handle_error(result.message); | ||
| } | ||
| if (maybe_diff) { | ||
| if (actions.isDebug()) { | ||
| actions.debug( | ||
| cache_message({ | ||
| hit: true, | ||
| message: "Github pr_diff cache", | ||
| extra: number, | ||
| }), | ||
| ); | ||
| } | ||
| return result; | ||
| } | ||
| return maybe_diff; | ||
| } | ||
| export async function pr_compare(branch: string) { | ||
| const state = Store.getState(); | ||
| const master_branch = state.master_branch; | ||
| const repo_path = state.repo_path; | ||
| invariant(master_branch, "master_branch must exist"); | ||
| invariant(repo_path, "repo_path must exist"); | ||
| if (actions.isDebug()) { | ||
| actions.debug( | ||
| cache_message({ | ||
| hit: false, | ||
| message: "Github pr_diff cache", | ||
| extra: number, | ||
| }), | ||
| ); | ||
| } | ||
| const master_branch_name = master_branch.replace(/^origin\//, ""); | ||
| // https://cli.github.com/manual/gh_pr_diff | ||
| const cli_result = await cli(`gh pr diff --color=never ${number}`); | ||
| // gh api repos/openai/openai/compare/master...chrome/publish/vine-1211---4h2xmw0o3ndvnt | ||
| const result = await gh_json<BranchCompare>( | ||
| `api repos/${repo_path}/compare/${master_branch_name}...${branch}`, | ||
| { branch }, | ||
| ); | ||
| if (cli_result.code !== 0) { | ||
| handle_error(cli_result.output); | ||
| if (result instanceof Error) { | ||
| handle_error(result.message); | ||
| } | ||
| actions.set((state) => { | ||
| state.cache_pr_diff[number] = cli_result.output; | ||
| }); | ||
| return cli_result.stdout; | ||
| return result; | ||
| } | ||
@@ -296,12 +285,74 @@ | ||
| type GhCmdOptions = { | ||
| branch?: string; | ||
| }; | ||
| // consistent handle gh cli commands returning json | ||
| // redirect to tmp file to avoid scrollback overflow causing scrollback to be cleared | ||
| async function gh_json<T>(command: string): Promise<T | Error> { | ||
| async function gh_json<T>(command: string, gh_options?: GhCmdOptions): Promise<T | Error> { | ||
| const gh_result = await gh(command, gh_options); | ||
| if (gh_result instanceof Error) { | ||
| return gh_result; | ||
| } | ||
| try { | ||
| const json = JSON.parse(gh_result); | ||
| return json as T; | ||
| } catch (error) { | ||
| return new Error(`gh_json JSON.parse: ${error}`); | ||
| } | ||
| } | ||
| // consistent handle gh cli commands | ||
| // redirect to tmp file to avoid scrollback overflow causing scrollback to be cleared | ||
| async function gh(command: string, gh_options?: GhCmdOptions): Promise<string | Error> { | ||
| const state = Store.getState(); | ||
| const actions = state.actions; | ||
| if (gh_options?.branch) { | ||
| const branch = gh_options.branch; | ||
| type CacheEntryByHeadRefName = (typeof state.cache_gh_cli_by_branch)[string][string]; | ||
| let cache: undefined | CacheEntryByHeadRefName = undefined; | ||
| if (branch) { | ||
| if (state.cache_gh_cli_by_branch[branch]) { | ||
| cache = state.cache_gh_cli_by_branch[branch][command]; | ||
| } | ||
| } | ||
| if (cache) { | ||
| if (actions.isDebug()) { | ||
| actions.debug( | ||
| cache_message({ | ||
| hit: true, | ||
| message: "gh cache", | ||
| extra: command, | ||
| }), | ||
| ); | ||
| } | ||
| return cache; | ||
| } | ||
| if (actions.isDebug()) { | ||
| actions.debug( | ||
| cache_message({ | ||
| hit: false, | ||
| message: "gh cache", | ||
| extra: command, | ||
| }), | ||
| ); | ||
| } | ||
| } | ||
| // hash command for unique short string | ||
| let hash = crypto.createHash("md5").update(command).digest("hex"); | ||
| let tmp_filename = safe_filename(`gh_json-${hash}`); | ||
| const tmp_pr_json = path.join(await get_tmp_dir(), `${tmp_filename}.json`); | ||
| let tmp_filename = safe_filename(`gh-${hash}`); | ||
| const tmp_filepath = path.join(await get_tmp_dir(), `${tmp_filename}`); | ||
| const options = { ignoreExitCode: true }; | ||
| const cli_result = await cli(`gh ${command} > ${tmp_pr_json}`, options); | ||
| const cli_result = await cli(`gh ${command} > ${tmp_filepath}`, options); | ||
@@ -313,9 +364,17 @@ if (cli_result.code !== 0) { | ||
| // read from file | ||
| const json_str = String(await fs.readFile(tmp_pr_json)); | ||
| try { | ||
| const json = JSON.parse(json_str); | ||
| return json; | ||
| } catch (error) { | ||
| return new Error(`gh_json JSON.parse: ${error}`); | ||
| let content = String(await fs.readFile(tmp_filepath)); | ||
| content = content.trim(); | ||
| if (gh_options?.branch) { | ||
| const branch = gh_options.branch; | ||
| actions.set((state) => { | ||
| if (!state.cache_gh_cli_by_branch[branch]) { | ||
| state.cache_gh_cli_by_branch[branch] = {}; | ||
| } | ||
| state.cache_gh_cli_by_branch[branch][command] = content; | ||
| }); | ||
| } | ||
| return content; | ||
| } | ||
@@ -416,4 +475,34 @@ | ||
| type MergeBaseCommit = { | ||
| author: unknown; | ||
| comments_url: string; | ||
| commit: unknown; | ||
| committer: unknown; | ||
| html_url: string; | ||
| node_id: string; | ||
| parents: unknown; | ||
| sha: string; | ||
| url: string; | ||
| }; | ||
| export type BranchCompare = { | ||
| ahead_by: number; | ||
| base_commit: unknown; | ||
| behind_by: number; | ||
| commits: unknown; | ||
| diff_url: string; | ||
| files: unknown; | ||
| html_url: string; | ||
| merge_base_commit: MergeBaseCommit; | ||
| patch_url: string; | ||
| permalink_url: string; | ||
| status: unknown; | ||
| total_commits: number; | ||
| url: string; | ||
| }; | ||
| const RE = { | ||
| non_alphanumeric_dash: /[^a-zA-Z0-9_-]+/g, | ||
| }; |
+0
-5
@@ -8,3 +8,2 @@ #!/usr/bin/env node | ||
| import fs from "node:fs/promises"; | ||
| import path from "node:path"; | ||
@@ -65,6 +64,2 @@ import * as Ink from "ink-cjs"; | ||
| const PATH = process.env["PATH"]; | ||
| const PATH_LIST = pretty_json(PATH.split(path.delimiter)); | ||
| actions.debug(`process.env.PATH ${PATH_LIST}`); | ||
| await ink.waitUntilExit(); | ||
@@ -71,0 +66,0 @@ |
| declare namespace NodeJS { | ||
| interface ProcessEnv { | ||
| PATH: string; | ||
| HOME: string; | ||
| DEV?: "true" | "false"; | ||
@@ -5,0 +6,0 @@ CLI_VERSION?: string; |
| import * as React from "react"; | ||
| import * as Ink from "ink-cjs"; | ||
| import { DateTime } from "luxon"; | ||
| export function LogTimestamp() { | ||
| return <Ink.Text dimColor>{DateTime.now().toFormat("[yyyy-MM-dd HH:mm:ss.SSS] ")}</Ink.Text>; | ||
| } |
Sorry, the diff of this file is too big to display
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
4847073
0.23%9419
1.17%