diff --git a/src/commands/changes/commit.tsx b/src/commands/changes/commit.tsx index efb4921..4bd1860 100644 --- a/src/commands/changes/commit.tsx +++ b/src/commands/changes/commit.tsx @@ -58,6 +58,7 @@ const DoChangesCommit = ({ return ( Committed changes successfully } diff --git a/src/commands/list.tsx b/src/commands/list.tsx index c5525e2..387aea5 100644 --- a/src/commands/list.tsx +++ b/src/commands/list.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { Box, Text } from 'ink'; import { CommandConfig } from '../types.js'; import { ForegroundColorName } from 'chalk'; @@ -6,62 +6,45 @@ import { LiteralUnion } from 'type-fest'; import { Loading } from '../components/loading.js'; import { SelectRootBranch } from '../components/select-root-branch.js'; import { treeToParentChildRecord } from '../utils/tree-helpers.js'; +import { useAsyncValue } from '../hooks/use-async-value.js'; +import { useGit } from '../hooks/use-git.js'; import { useGitHelpers } from '../hooks/use-git-helpers.js'; import { useTree } from '../hooks/use-tree.js'; -interface TextStyle { - color: LiteralUnion; - dimColor?: boolean; -} - -const styleMap: TextStyle[] = [ - { color: 'cyan' }, - { color: 'blue' }, - { color: 'yellow' }, - { color: 'magentaBright' }, - { color: 'green' }, - { color: 'blueBright' }, - { color: 'yellowBright' }, - { color: 'magenta' }, - { color: 'cyanBright' }, -]; - -const DisplayElementText = ({ elements }: { elements: DisplayElement[] }) => { - return ( - <> - {elements.map((element, index) => { - const style = styleMap[index % styleMap.length] as TextStyle; - return ( - - {element.symbols} - - ); - })} - - ); -}; - -const Spaces = ({ count }: { count: number }) => { - return <>{Array(count).fill(' ')}; -}; - export const List = () => { + const git = useGit(); const { currentBranch } = useGitHelpers(); - const { get, rootBranchName } = useTree(); + const { get, rootBranchName, currentTree } = useTree(); const treeParentChildRecord = useMemo( () => treeToParentChildRecord(get()), [] ); + const getBranchNeedsRebaseRecord = useCallback(async () => { + const record: Record = {}; + await Promise.all( + currentTree.map(async (_node) => { + if (!_node.parent) return null; + + record[_node.key] = await git.needsRebaseOnto({ + branch: _node.key, + ontoBranch: _node.parent, + }); + return null; + }) + ); + return record; + }, [currentTree, git.needsRebaseOnto]); + + const branchNeedsRebaseRecord = useAsyncValue({ + getValue: getBranchNeedsRebaseRecord, + }); + if (!rootBranchName) { return ; } - if (currentBranch.isLoading) { + if (currentBranch.isLoading || branchNeedsRebaseRecord.isLoading) { return ; } @@ -90,13 +73,14 @@ export const List = () => { ]} /> {isCurrent ? ' 👉 ' : ''} {node.name}{' '} + {branchNeedsRebaseRecord.value?.[node.name] && ( + (Needs rebase) + )} ); @@ -105,6 +89,46 @@ export const List = () => { ); }; +interface TextStyle { + color: LiteralUnion; + dimColor?: boolean; +} + +const styleMap: TextStyle[] = [ + { color: 'cyan' }, + { color: 'blue' }, + { color: 'yellow' }, + { color: 'magentaBright' }, + { color: 'green' }, + { color: 'blueBright' }, + { color: 'yellowBright' }, + { color: 'magenta' }, + { color: 'cyanBright' }, +]; + +const DisplayElementText = ({ elements }: { elements: DisplayElement[] }) => { + return ( + <> + {elements.map((element, index) => { + const style = styleMap[index % styleMap.length] as TextStyle; + return ( + + {element.symbols} + + ); + })} + + ); +}; + +const Spaces = ({ count }: { count: number }) => { + return <>{Array(count).fill(' ')}; +}; + interface DisplayElement { symbols: string; } diff --git a/src/commands/sync.tsx b/src/commands/sync.tsx index ee8abdd..941cf01 100644 --- a/src/commands/sync.tsx +++ b/src/commands/sync.tsx @@ -1,27 +1,90 @@ -import React from 'react'; +import ErrorDisplay from '../components/error-display.js'; +import React, { useCallback } from 'react'; +import { Action, useAction } from '../hooks/use-action.js'; import { CommandConfig, CommandProps } from '../types.js'; +import { Loading } from '../components/loading.js'; import { RecursiveRebaser } from '../components/recursive-rebaser.js'; import { SelectRootBranch } from '../components/select-root-branch.js'; import { Text } from 'ink'; +import { useGit } from '../hooks/use-git.js'; +import { useGitHelpers } from '../hooks/use-git-helpers.js'; import { useTree } from '../hooks/use-tree.js'; const Sync = (_: CommandProps) => { + const { currentBranch } = useGitHelpers(); const { rootBranchName } = useTree(); if (!rootBranchName) { return ; } - // todo: prompt to delete branches + if (currentBranch.isLoading) { + return ; + } + + return ( + + ); +}; + +const DoSync = ({ + rootBranchName, + currentBranchName, +}: { + rootBranchName: string; + currentBranchName: string; +}) => { + const result = useSyncAction({ + rootBranchName, + currentBranchName, + }); + + if (result.isError) { + return ; + } + + if (result.isLoading) { + return ; + } + // todo: prompt to delete branches return ( Synced successfully} /> ); }; +type UseSyncActionResult = Action; +const useSyncAction = ({ + currentBranchName, + rootBranchName, +}: { + currentBranchName: string; + rootBranchName: string; +}): UseSyncActionResult => { + const git = useGit(); + + const performAction = useCallback(async () => { + await git.checkout(rootBranchName); + await git.pull(); + await git.checkout(currentBranchName); + }, [git]); + + const action = useAction({ + asyncAction: performAction, + }); + + return { + ...action, + } as UseSyncActionResult; +}; + export const syncConfig: CommandConfig = { description: 'Sync your local changes with the remote server', usage: 'sync', diff --git a/src/components/loading.tsx b/src/components/loading.tsx index 45eebd2..7d2aec5 100644 --- a/src/components/loading.tsx +++ b/src/components/loading.tsx @@ -1,6 +1,44 @@ -import React from 'react'; -import { Text } from 'ink'; +import React, { useEffect, useMemo, useState } from 'react'; +import { Box, Text } from 'ink'; export const Loading = () => { - return Loading...; + return ; +}; + +const WIDTH = 3; +const SYMBOLS = ['◔', '◑', '◕', '●']; + +export const InfiniteLoadingAnimation = () => { + const [barStartIndex, setBarStartIndex] = useState(0); + const [symbolIndex, setSymbolIndex] = useState(0); + + useEffect(() => { + const loadingBarInterval = setInterval(() => { + setBarStartIndex((prev) => (prev + 1) % WIDTH); + }, 150); + + const symbolInterval = setInterval(() => { + setSymbolIndex((prev) => (prev + 1) % SYMBOLS.length); + }, 200); + + return () => { + clearInterval(loadingBarInterval); + clearInterval(symbolInterval); + }; + }, []); + + // alternative loading bar concept + const currentBar = useMemo(() => { + const blocks = Array(WIDTH).fill('█') as string[]; + blocks.splice(barStartIndex, 1, ' '); + return blocks; + }, [barStartIndex]); + + return ( + + {SYMBOLS[symbolIndex]} | + {/*{currentBar.join('')}*/} + Loading + + ); }; diff --git a/src/components/rebase-conflict.tsx b/src/components/rebase-conflict.tsx index e4cdbb9..2e0fdb2 100644 --- a/src/components/rebase-conflict.tsx +++ b/src/components/rebase-conflict.tsx @@ -15,6 +15,9 @@ export const RebaseConflict = () => { 3. Continue the rebase with{' '} gum continue + + OR - Cancel with git rebase --abort + ); }; diff --git a/src/components/recursive-rebaser.tsx b/src/components/recursive-rebaser.tsx index 2a16911..637b160 100644 --- a/src/components/recursive-rebaser.tsx +++ b/src/components/recursive-rebaser.tsx @@ -10,12 +10,14 @@ import { RebaseConflict } from './rebase-conflict.js'; export const RecursiveRebaser = ({ baseBranch, + endBranch, successStateNode, }: { baseBranch: string; + endBranch: string; successStateNode: ReactNode; }) => { - const result = useRecursiveRebase({ baseBranch }); + const result = useRecursiveRebase({ baseBranch, endBranch }); if (result.isError) { return ; diff --git a/src/hooks/use-recursive-rebase.tsx b/src/hooks/use-recursive-rebase.tsx index 0e8f353..02f155f 100644 --- a/src/hooks/use-recursive-rebase.tsx +++ b/src/hooks/use-recursive-rebase.tsx @@ -16,8 +16,10 @@ export type RebaseActionLog = RebaseAction & { export const useRecursiveRebase = ({ baseBranch, + endBranch, }: { baseBranch: string; + endBranch: string; }): UseRecursiveRebaseResult => { const git = useGit(); const { currentTree } = useTree(); @@ -31,6 +33,7 @@ export const useRecursiveRebase = ({ await recursiveRebase({ tree: currentTree, baseBranch: baseBranch, + endBranch: endBranch, events: { rebased: (action, state) => { if (state === 'STARTED') { diff --git a/src/services/git.ts b/src/services/git.ts index 02c95d5..d62def1 100644 --- a/src/services/git.ts +++ b/src/services/git.ts @@ -22,6 +22,15 @@ export interface GitService { }) => Promise; isRebasing: () => Promise; rebaseContinue: () => Promise; + mergeBaseBranch: (branchA: string, branchB: string) => Promise; + latestCommitFor: (branch: string) => Promise; + needsRebaseOnto: (args: { + branch: string; + ontoBranch: string; + }) => Promise; + isClosedOnRemote: (branch: string) => Promise; + fetchPrune: () => Promise; + pull: () => Promise; } export const createGitService = ({ @@ -81,5 +90,75 @@ export const createGitService = ({ rebaseContinue: async () => { await gitEngine.rebase(['--continue']); }, + mergeBaseBranch: async (branchA: string, branchB: string) => { + const result = await gitEngine.raw([ + 'merge-base', + branchA, + branchB, + ]); + /* + * The result is the commit SHA of the most recent "ancestor" commit between both branches. + * Because this is a raw() command, it also includes a "\n" at the end of the commit SHA that we remove + */ + const commonAncestorCommit = result.replace('\n', ''); + return commonAncestorCommit; + }, + latestCommitFor: async (branch: string) => { + const { latest } = await gitEngine.log([ + '-n', // specify a number of commits to return + '1', // only return 1 (the latest) + branch, + ]); + + return latest?.hash ?? null; + }, + needsRebaseOnto: async ({ + branch, + ontoBranch, + }: { + branch: string; + ontoBranch: string; + }) => { + const result = await gitEngine.raw([ + 'merge-base', + branch, + ontoBranch, + ]); + /* + * The result is the commit SHA of the most recent "ancestor" commit between both branches. + * Because this is a raw() command, it also includes a "\n" at the end of the commit SHA that we remove + */ + const commonAncestorCommit = result.replace('\n', ''); + + const { latest } = await gitEngine.log([ + '-n', // specify a number of commits to return + '1', // only return 1 (the latest) + ontoBranch, + ]); + + const ontoBranchLatestHash = latest?.hash ?? null; + + return ontoBranchLatestHash !== commonAncestorCommit; + }, + isClosedOnRemote: async (branch: string) => { + const { all } = await gitEngine.branch(['-a']); + const remoteBranchName = `remotes/origin/${branch}`; + + if (all.includes(remoteBranchName)) { + return false; + } + + if (all.includes(branch)) { + return true; + } + + return false; + }, + fetchPrune: async () => { + await gitEngine.fetch(['--prune', 'origin']); + }, + pull: async () => { + await gitEngine.pull(['--ff-only', '--prune']); + }, }; }; diff --git a/src/services/resolver.ts b/src/services/resolver.ts index 0e5bd97..358bebd 100644 --- a/src/services/resolver.ts +++ b/src/services/resolver.ts @@ -5,10 +5,12 @@ import { treeToParentChildRecord } from '../utils/tree-helpers.js'; export const recursiveRebase = async ({ tree, baseBranch, + endBranch, events, }: { tree: Tree; baseBranch: string; + endBranch: string; events?: { rebased: ( rebaseAction: RebaseAction, @@ -46,7 +48,7 @@ export const recursiveRebase = async ({ rebasedEventHandler(rebaseAction, 'COMPLETED'); } - await git.checkout(baseBranch); + await git.checkout(endBranch); completeEventHandler(); };