diff --git a/CHANGELOG.md b/CHANGELOG.md index 658290a..5ec7e28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to the "gops" extension will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +## [0.0.12] + +### Added + +- Added git commit graph +- Added options to stage and unstage all files +- Added option to stage and unstage individual files +- Added commit icon +- Added context menu for renaming and deleting branch + +### Fixed + +- Improved logic to refresh treeview in various stages + ## [0.0.5] - Added ability to perform diff checks on changed files diff --git a/README.md b/README.md index fda85bd..7d7e7a5 100644 --- a/README.md +++ b/README.md @@ -12,24 +12,35 @@ Git Operations - Visual Git Toolkit for VS Code ## Features -### Tree View - -- Toolbar with following options - - Commit - - Create Branch from Current - - Pull - - Push - - Refresh treeview -- Local Branches - - Checkout - - Delete - - New Branch - - Rename Branch -- Remote Branches -- Local Changes -- Staged Changes -- Tags -- Stash +**Branch Management** +- Create, checkout, delete, and rename branches +- Publish local branches to remote with upstream tracking +- Visual git graph showing commit history per branch +- Ahead/behind tracking for local branches + +**File Operations** +- Stage and unstage files individually or all at once +- View diffs for changed files +- Auto-refresh on file save + +**Remote Operations** +- Push, pull, and fetch changes +- Fetch with automatic pruning of deleted remote branches + +**Commit Workflow** +- Commit staged files with custom messages +- Commit button only appears when files are staged + +**User Interface** +- Clean tree view with organized sections (branches, changes, staged, tags, stash) +- Context-aware inline buttons and menus +- Modal dialogs for confirmations +- Syntax highlighting and VSCode theme integration + +**Developer Features** +- Comprehensive logging and error reporting +- 80+ unit and integration tests +- Auto-refresh watchers for git state changes ## Requirements diff --git a/media/gitGraph.css b/media/gitGraph.css index a7a9abc..95a9701 100644 --- a/media/gitGraph.css +++ b/media/gitGraph.css @@ -145,3 +145,13 @@ svg.graph { display: block; overflow: visible; } + +.refs { + color: var(--vscode-terminal-ansiGreen); + font-size: 10px; + background: var(--vscode-badge-background); + padding: 2px 6px; + border-radius: 3px; + display: inline-block; + margin-left: 4px; +} diff --git a/package.json b/package.json index 2e1000e..bba99c1 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,26 @@ "command": "gops.showGitGraph", "title": "Show Git Graph", "icon": "$(graph)" + }, + { + "command": "gops.publishBranch", + "title": "Publish Branch", + "icon": "$(cloud-upload)" + }, + { + "command": "gops.fetch", + "title": "Fetch", + "icon": "$(cloud-download)" + }, + { + "command": "gops.stashChanges", + "title": "Stash Changes", + "icon": "$(save)" + }, + { + "command": "gops.popStash", + "title": "Pop Stash", + "icon": "$(remove)" } ], "viewsContainers": { @@ -169,6 +189,16 @@ "command": "gops.branch.current", "when": "view == gitOpsTreeview", "group": "navigation" + }, + { + "command": "gops.fetch", + "when": "view == gitOpsTreeview", + "group": "navigation" + }, + { + "command": "gops.stashChanges", + "when": "view == gitOpsTreeview && gops.hasChangedFiles == true", + "group": "navigation" } ], "view/item/context": [ @@ -181,7 +211,7 @@ { "command": "gops.checkout", "title": "Checkout Branch", - "when": "view == gitOpsTreeview && viewItem == localBranches", + "when": "view == gitOpsTreeview && (viewItem == localBranches || viewItem == localBranches.noUpstream)", "group": "navigation" }, { @@ -211,14 +241,25 @@ { "command": "gops.deleteBranch", "title": "Delete Branch", - "when": "view == gitOpsTreeview && viewItem == localBranches", + "when": "view == gitOpsTreeview && (viewItem == localBranches || viewItem == localBranches.noUpstream)", "group": "navigation" }, { "command": "gops.renameBranch", "title": "Rename Branch", - "when": "view == gitOpsTreeview && viewItem == localBranches", + "when": "view == gitOpsTreeview && (viewItem == localBranches || viewItem == localBranches.noUpstream)", "group": "navigation" + }, + { + "command": "gops.publishBranch", + "title": "Publish Branch", + "when": "viewItem == localBranches.noUpstream", + "group": "inline" + }, + { + "command": "gops.popStash", + "when": "view == gitOpsTreeview && viewItem == stash", + "group": "inline" } ] } diff --git a/src/commands/CommandRegistrar.ts b/src/commands/CommandRegistrar.ts index 5d4a3d4..54e5e15 100644 --- a/src/commands/CommandRegistrar.ts +++ b/src/commands/CommandRegistrar.ts @@ -41,8 +41,15 @@ export class CommandRegistrar { ); this.register(COMMANDS.COMMIT, () => this.delegate.commit()); this.register(COMMANDS.CREATE_TAG, () => this.delegate.createTag()); - this.register(COMMANDS.SHOW_GIT_GRAPH, (node) => this.delegate.showGitGraph(node) + this.register(COMMANDS.SHOW_GIT_GRAPH, (node) => + this.delegate.showGitGraph(node), ); + this.register(COMMANDS.PUBLISH_BRANCH, (node) => + this.delegate.publishBranch(node), + ); + this.register(COMMANDS.FETCH, () => this.delegate.fetch()); + this.register(COMMANDS.POP_STASH, (node) => this.delegate.popStash(node)); + this.register(COMMANDS.STASH_CHANGES, () => this.delegate.stashChanges()); } private register( diff --git a/src/commands/Commands.ts b/src/commands/Commands.ts index cb859f1..592d0db 100644 --- a/src/commands/Commands.ts +++ b/src/commands/Commands.ts @@ -15,4 +15,8 @@ export const COMMANDS = { UNSTAGE_ALL_FILES: "gops.unstageAllFiles", COMMIT: "gops.commit", SHOW_GIT_GRAPH: "gops.showGitGraph", + PUBLISH_BRANCH: "gops.publishBranch", + FETCH: "gops.fetch", + POP_STASH: "gops.popStash", + STASH_CHANGES: "gops.stashChanges", } as const; diff --git a/src/commands/GitOperationsDelegate.ts b/src/commands/GitOperationsDelegate.ts index d64d109..a954f86 100644 --- a/src/commands/GitOperationsDelegate.ts +++ b/src/commands/GitOperationsDelegate.ts @@ -6,6 +6,9 @@ import { GitTreeNode } from "../gopstree/types"; import { ChangedFileNode } from "../gopstree/nodes/ChangedFileNode"; import { StagedFileNode } from "../gopstree/nodes/StagedFileNode"; import { GitGraphPanel } from "../gopswebpanel/GitGraphPanel"; +import { LocalBranchNode } from "../gopstree/nodes/LocalBranchNode"; +import { Notifications } from "../notifications/Notifications"; +import { StashNode } from "../gopstree/nodes/StashNode"; export class GitOperationsDelegate { constructor( @@ -30,14 +33,18 @@ export class GitOperationsDelegate { } async deleteBranch(node: GitTreeNode): Promise { - if (!node || !("branchName" in node)) { + if (!node || !(node instanceof LocalBranchNode)) { + return; + } + + if (node.isCurrent) { + Notifications.error(`Switch to another branch before deleting.`); return; } - const confirm = await vscode.window.showWarningMessage( + const confirm = await Notifications.choice( `Are you sure you want to delete branch "${node.branchName}"?`, - { modal: true }, - "Delete", + ["Delete"], ); if (confirm !== "Delete") { @@ -112,7 +119,12 @@ export class GitOperationsDelegate { } async showDiff(node: GitTreeNode): Promise { - if (!node || !(node instanceof ChangedFileNode) || !node.fileName) { + if ( + !node || + (!(node instanceof ChangedFileNode) && + !(node instanceof StagedFileNode)) || + !node.fileName + ) { return; } @@ -178,4 +190,34 @@ export class GitOperationsDelegate { async showGitGraph(branchName: string): Promise { await GitGraphPanel.createOrShow(branchName, this.gitService); } + + async publishBranch(node: GitTreeNode): Promise { + if (!node || !("branchName" in node)) { + return; + } + + await this.gitService.publishBranch(node.branchName); + await this.treeDataProvider.refreshLocalBranchesNode(); + await this.treeDataProvider.refreshRemoteBranchesNode(); + } + + async fetch(): Promise { + await this.gitService.fetch(); + await this.treeDataProvider.refreshLocalBranchesNode(); + this.treeDataProvider.refreshRemoteBranchesNode(); + } + + async popStash(node: GitTreeNode): Promise { + if (!node || !(node instanceof StashNode)) { + return; + } + + await this.gitService.popStash(node.stashRef); + this.treeDataProvider.refresh(); + } + + async stashChanges(): Promise { + await this.gitService.stashChanges(); + this.treeDataProvider.refresh(); + } } diff --git a/src/extension.ts b/src/extension.ts index 02f3bd1..cf53478 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -5,9 +5,21 @@ import { GitService } from "./services/GitService"; import { FileService } from "./services/FileService"; import { DiffService } from "./services/DiffService"; import { TreeDataProvider } from "./gopstree/TreeDataProvider"; +import { Notifications } from "./notifications/Notifications"; -export function activate(context: vscode.ExtensionContext) { +export async function activate(context: vscode.ExtensionContext) { const gitService = new GitService(); + + // Check git availability + const isGitAvailable = await GitService.isGitAvailable(); + if (!isGitAvailable) { + Notifications.error( + "Gops requires Git to be installed and available in PATH. Please install Git and restart VS Code.", + true, + ); + return; + } + const fileService = new FileService(context.globalStorageUri.fsPath); const diffService = new DiffService(fileService, gitService); const treeDataProvider = new TreeDataProvider(gitService); @@ -34,7 +46,7 @@ export function activate(context: vscode.ExtensionContext) { treeDataProvider.refreshChangesNode(); treeDataProvider.refreshStagedNode(); }); - + context.subscriptions.push(treeView, onSave, gitWatcher); console.log("Gops extension activated."); } diff --git a/src/gopstree/ContextValue.ts b/src/gopstree/ContextValue.ts index 83a9961..5e0b58a 100644 --- a/src/gopstree/ContextValue.ts +++ b/src/gopstree/ContextValue.ts @@ -1,20 +1,33 @@ export enum ContextValue { Repository = "repository", - Changes = "changedFile", - StagedChanges = "stagedFile", + + //Context related to Local Branches + LocalBranchesSection = "localBranchesSection", LocalBranches = "localBranches", LocalBranchesCurrent = "localBranches.current", + LocalBranchesNoUpstream = "localBranches.noUpstream", + + //Context related to Remote Branches + RemoteBranchesSection = "remoteBranchesSection", RemoteBranches = "remoteBranches", Branch = "branch", File = "file", Stash = "stash", Commit = "commit", - LocalBranchesSection = "localBranchesSection", - RemoteBranchesSection = "remoteBranchesSection", + + //Context related to Changes ChangesSection = "changesSection", ChangesSectionEmpty = "changesSectionEmpty", + Changes = "changedFile", + + //Context related to Staged Changes StagedChangesSection = "stagedChangesSection", StagedChangesSectionEmpty = "stagedChangesSectionEmpty", + StagedChanges = "stagedFile", + + //Context related to Tags TagsSection = "tagsSection", + + //Context related to Stash StashSection = "stashSection", } diff --git a/src/gopstree/TreeDataProvider.ts b/src/gopstree/TreeDataProvider.ts index be439f5..baa7e0f 100644 --- a/src/gopstree/TreeDataProvider.ts +++ b/src/gopstree/TreeDataProvider.ts @@ -17,6 +17,7 @@ import { TagsSection } from "./nodes/TagsSection"; import { StashSection } from "./nodes/StashSection"; import { ContextValue } from "./ContextValue"; import { CONTEXT_KEYS } from "../constants/ContextKeys"; +import { StashNode } from "./nodes/StashNode"; export class TreeDataProvider implements vscode.TreeDataProvider { private _onDidChangeTreeData = new vscode.EventEmitter< @@ -73,7 +74,13 @@ export class TreeDataProvider implements vscode.TreeDataProvider { ): Promise { const branches = await this.gitService.getLocalBranches(); const allLocalBranches = branches.map((b) => { - const node = new LocalBranchNode(b.name, b.current, b.ahead, b.behind); + const node = new LocalBranchNode( + b.name, + b.current, + b.ahead, + b.behind, + b.hasUpstream, + ); node.parent = parent; return node; }); @@ -124,14 +131,11 @@ export class TreeDataProvider implements vscode.TreeDataProvider { private async getStash(): Promise { const stash = await this.gitService.getStash(); - return stash.map( - (s) => - new TreeItemModel( - { label: s }, - NodeType.Stash, - vscode.TreeItemCollapsibleState.None, - ), - ); + return stash.map((s) => { + const node = new StashNode(s.ref, s.message); + console.debug(node.toString()); + return node; + }); } private async getRepositoryChildren(): Promise { @@ -232,7 +236,7 @@ export class TreeDataProvider implements vscode.TreeDataProvider { this._onDidChangeTreeData.fire(this.localBranchesNode); } - refreshRemoteBranchesNode(): void { + async refreshRemoteBranchesNode(): Promise { if (!this.remoteBranchesNode) { return; } diff --git a/src/gopstree/nodes/LocalBranchNode.ts b/src/gopstree/nodes/LocalBranchNode.ts index dbfe4c1..8069f99 100644 --- a/src/gopstree/nodes/LocalBranchNode.ts +++ b/src/gopstree/nodes/LocalBranchNode.ts @@ -9,12 +9,13 @@ import { import { COMMANDS } from "../../commands/Commands"; export class LocalBranchNode extends TreeItemModel { - public override command?: vscode.Command; + public override command?: vscode.Command; constructor( public readonly branchName: string, public readonly isCurrent: boolean, - public readonly ahead?: number, - public readonly behind?: number, + public readonly ahead: number, + public readonly behind: number, + public readonly hasUpstream: boolean, ) { const fomatted = formatLocalBranchLabel( branchName, @@ -30,10 +31,16 @@ export class LocalBranchNode extends TreeItemModel { NodeType.Local, vscode.TreeItemCollapsibleState.None, ); - this.contextValue = isCurrent - ? ContextValue.LocalBranchesCurrent - : ContextValue.LocalBranches; - + + // Set context value based on upstream status + if (!hasUpstream) { + this.contextValue = ContextValue.LocalBranchesNoUpstream; + } else if (isCurrent) { + this.contextValue = ContextValue.LocalBranchesCurrent; + } else { + this.contextValue = ContextValue.LocalBranches; + } + this.command = { command: COMMANDS.SHOW_GIT_GRAPH, title: "Open Git Graph", diff --git a/src/gopstree/nodes/StashNode.ts b/src/gopstree/nodes/StashNode.ts new file mode 100644 index 0000000..012a6c3 --- /dev/null +++ b/src/gopstree/nodes/StashNode.ts @@ -0,0 +1,23 @@ +import { ContextValue } from "../ContextValue"; +import { NodeType } from "./NodeType"; +import { TreeItemModel } from "../TreeItemModel"; +import * as vscode from "vscode"; + +export class StashNode extends TreeItemModel { + constructor( + public readonly stashRef: string, + public readonly stashMessage: string, + ) { + super( + { label: stashMessage }, + NodeType.Stash, + vscode.TreeItemCollapsibleState.None, + ); + this.contextValue = ContextValue.Stash; + this.iconPath = new vscode.ThemeIcon("save"); + } + + public toString(): string { + return `StashNode(${this.stashRef}, ${this.stashMessage})`; + } +} diff --git a/src/gopswebpanel/GitGraphWebview.ts b/src/gopswebpanel/GitGraphWebview.ts index 4be101b..2c80342 100644 --- a/src/gopswebpanel/GitGraphWebview.ts +++ b/src/gopswebpanel/GitGraphWebview.ts @@ -13,12 +13,12 @@ export function renderGitGraph( - +
@@ -86,9 +86,16 @@ export function renderGitGraph( tr.appendChild(hashTd); const msgTd = document.createElement('td'); + let msgText = commit.message; + if (commit.isMergeCommit) { + msgText = '[MERGE] ' + msgText; + } msgTd.innerHTML = '' + - (commit.message.length > 60 ? commit.message.substring(0, 60) + '...' : commit.message) + + (msgText.length > 60 ? msgText.substring(0, 60) + '...' : msgText) + ''; + if (commit.refs) { + msgTd.innerHTML += ' ' + commit.refs + ''; + } tr.appendChild(msgTd); const authorTd = document.createElement('td'); diff --git a/src/models/AheadBehindModel.ts b/src/models/AheadBehindModel.ts deleted file mode 100644 index b405ac9..0000000 --- a/src/models/AheadBehindModel.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface AheadBehindModel { - ahead: number; - behind: number; -} diff --git a/src/models/BranchInfoModel.ts b/src/models/BranchInfoModel.ts new file mode 100644 index 0000000..096b809 --- /dev/null +++ b/src/models/BranchInfoModel.ts @@ -0,0 +1,5 @@ +export interface BranchInfoModel { + ahead: number; + behind: number; + hasUpstream: boolean; +} diff --git a/src/models/GitCommitModel.ts b/src/models/GitCommitModel.ts index f79f835..836ded2 100644 --- a/src/models/GitCommitModel.ts +++ b/src/models/GitCommitModel.ts @@ -3,4 +3,6 @@ export interface GitCommitModel { message: string; author: string; date: string; + isMergeCommit: boolean; + refs: string; } diff --git a/src/models/LocalBranchModel.ts b/src/models/LocalBranchModel.ts index e1d45f6..0a57bae 100644 --- a/src/models/LocalBranchModel.ts +++ b/src/models/LocalBranchModel.ts @@ -3,4 +3,5 @@ export interface LocalBranchModel { current: boolean; ahead: number; behind: number; + hasUpstream: boolean; } diff --git a/src/notifications/Notifications.ts b/src/notifications/Notifications.ts index c3b282e..9f67576 100644 --- a/src/notifications/Notifications.ts +++ b/src/notifications/Notifications.ts @@ -2,21 +2,41 @@ import * as vscode from "vscode"; import { Logger } from "../logging/Logger"; export class Notifications { - static info(message: string): Thenable { - return vscode.window.showInformationMessage(message); + static info( + message: string, + modal: boolean = false, + ): Thenable { + return vscode.window.showInformationMessage(message, { modal }); } - static warning(message: string): Thenable { - return vscode.window.showWarningMessage(message); + static success( + message: string, + modal: boolean = false, + ): Thenable { + return vscode.window.showInformationMessage(`✓ ${message}`, { modal }); } - static error(message: string): Thenable { - return vscode.window.showErrorMessage(message); + static warning( + message: string, + modal: boolean = false, + ): Thenable { + return vscode.window.showWarningMessage(message, { modal }); } - static async errorWithOutput(message: string): Promise { + static error( + message: string, + modal: boolean = false, + ): Thenable { + return vscode.window.showErrorMessage(message, { modal }); + } + + static async errorWithOutput( + message: string, + modal: boolean = false, + ): Promise { const selection = await vscode.window.showErrorMessage( message, + { modal }, "Show Output", ); @@ -24,4 +44,12 @@ export class Notifications { Logger.show(); } } + + static async choice( + message: string, + buttons: string[], + modal: boolean = true, + ): Promise { + return vscode.window.showWarningMessage(message, { modal }, ...buttons); + } } diff --git a/src/services/GitService.ts b/src/services/GitService.ts index cc7e72e..4a64ddd 100644 --- a/src/services/GitService.ts +++ b/src/services/GitService.ts @@ -12,7 +12,8 @@ import { Logger } from "../logging/Logger"; import { Notifications } from "../notifications/Notifications"; import { LocalBranchModel } from "../models/LocalBranchModel"; import { RemoteBranchModel } from "../models/RemoteBranchModel"; -import { AheadBehindModel } from "../models/AheadBehindModel"; +import { BranchInfoModel } from "../models/BranchInfoModel"; +import { GitCommitModel } from "../models/GitCommitModel"; export class GitService { private git: SimpleGit; @@ -31,6 +32,16 @@ export class GitService { this.git = simpleGit(finalPath); } + static async isGitAvailable(): Promise { + try { + const git = simpleGit(); + await git.version(); + return true; + } catch { + return false; + } + } + async getStatus(): Promise { return this.git.status(); } @@ -102,19 +113,35 @@ export class GitService { } async getLocalBranches(): Promise { - const branches = await this.git.branchLocal(); + const raw = await this.git.branch(["-vv"]); - return branches.all.map((name) => { - const branch = branches.branches[name]; + return raw.all.map((name) => { + const branch = raw.branches[name]; return { name, - current: name === branches.current, - ...this.parseAheadBehind(branch.label), + current: name === raw.current, + ...this.parseBranchInfo(branch.label), }; }); } + async popStash(stashId: string): Promise { + await this.executeGitAction( + () => this.git.stash(["pop", stashId]), + `Popped stash ${stashId}`, + `Failed to pop stash ${stashId}`, + ); + } + + async stashChanges(): Promise { + await this.executeGitAction( + () => this.git.stash(), + "Changes stashed successfully", + "Failed to stash changes", + ); + } + async getRemotes(): Promise { return this.git.getRemotes(); } @@ -133,9 +160,12 @@ export class GitService { return (await this.git.tags()).all; } - async getStash(): Promise { + async getStash(): Promise<{ ref: string; message: string }[]> { const stashList = await this.git.stashList(); - return stashList.all.map((s) => s.message); + return stashList.all.map((s, index) => ({ + ref: `stash@{${index}}`, + message: s.message, + })); } async getLog(): Promise { @@ -200,28 +230,49 @@ export class GitService { ); } - async getBranchCommits(branchName: string): Promise< - { - hash: string; - message: string; - author: string; - date: string; - }[] - > { + async getBranchCommits(branchName: string): Promise { return this.executeGitAction( async () => { - const log = await this.git.log([branchName]); - return log.all.map((c) => ({ - hash: c.hash.substring(0, 7), + const log = await this.git.log({ + [branchName]: null, + format: { + hash: "%h", + message: "%s", + author: "%an", + date: "%ai", + parentCount: "%P", + refs: "%D", + }, + }); + return log.all.map((c: any) => ({ + hash: c.hash, message: c.message, - author: c.author_name, + author: c.author, date: c.date, + isMergeCommit: c.parentCount.split(" ").length > 1, + refs: c.refs || "", })); }, `Loaded commits for branch ${branchName}`, `Failed to load commits for branch ${branchName}`, ); } + + async publishBranch(branchName: string): Promise { + await this.executeGitAction( + () => this.git.push(["--set-upstream", "origin", branchName]), + `Branch ${branchName} published to origin`, + `Failed to publish branch ${branchName}`, + ); + } + + async fetch(): Promise { + await this.executeGitAction( + () => this.git.fetch(["--prune"]), + "Fetched latest changes", + "Failed to fetch changes", + ); + } // #endregion getRepoName(): string { @@ -261,13 +312,15 @@ export class GitService { } } - private parseAheadBehind(label: string): AheadBehindModel { + private parseBranchInfo(label: string): BranchInfoModel { const aheadMatch = label.match(/ahead (\d+)/); const behindMatch = label.match(/behind (\d+)/); + const hasUpstream = /^\[[^\]]+\]/.test(label) && !/: gone/.test(label); return { ahead: aheadMatch ? parseInt(aheadMatch[1], 10) : 0, behind: behindMatch ? parseInt(behindMatch[1], 10) : 0, + hasUpstream, }; } } diff --git a/test/integration/branch.test.ts b/test/integration/branch.test.ts index 5bfe7fd..a5c3901 100644 --- a/test/integration/branch.test.ts +++ b/test/integration/branch.test.ts @@ -20,7 +20,10 @@ suite("Branch", function () { await vscode.commands.executeCommand(COMMANDS.CREATE_BRANCH_FROM_CURRENT); (vscode.window as any).showInputBox = stub; - assert.ok(true, `${COMMANDS.CREATE_BRANCH_FROM_CURRENT} completed without error`); + assert.ok( + true, + `${COMMANDS.CREATE_BRANCH_FROM_CURRENT} completed without error`, + ); }); test(`${COMMANDS.DELETE_BRANCH} should execute without error`, async function () { @@ -37,4 +40,9 @@ suite("Branch", function () { await vscode.commands.executeCommand(COMMANDS.CHECKOUT_BRANCH); assert.ok(true, `${COMMANDS.CHECKOUT_BRANCH} completed without error`); }); + + test(`${COMMANDS.PUBLISH_BRANCH} should execute without error`, async function () { + await vscode.commands.executeCommand(COMMANDS.PUBLISH_BRANCH); + assert.ok(true, `${COMMANDS.PUBLISH_BRANCH} completed without error`); + }); }); diff --git a/test/integration/commands.test.ts b/test/integration/commands.test.ts index 8d7112c..0ec1891 100644 --- a/test/integration/commands.test.ts +++ b/test/integration/commands.test.ts @@ -29,6 +29,10 @@ suite("Commands", function () { COMMANDS.UNSTAGE_FILE, COMMANDS.UNSTAGE_ALL_FILES, COMMANDS.COMMIT, + COMMANDS.SHOW_GIT_GRAPH, + COMMANDS.PUBLISH_BRANCH, + COMMANDS.FETCH, + COMMANDS.POP_STASH, ]; for (const command of expectedCommands) { @@ -67,4 +71,24 @@ suite("Commands", function () { ); } }); + + test("gops.fetch should execute without error", async function () { + await vscode.commands.executeCommand("gops.fetch"); + assert.ok(true, "gops.fetch completed without error"); + }); + + test("gops.popStash should execute without error", async function () { + await vscode.commands.executeCommand(COMMANDS.POP_STASH); + assert.ok(true, "gops.popStash completed without error"); + }); + + test(`${COMMANDS.STASH_CHANGES} should execute without error`, async function () { + await vscode.commands.executeCommand(COMMANDS.STASH_CHANGES); + assert.ok(true, `${COMMANDS.STASH_CHANGES} completed without error`); + }); + + test(`${COMMANDS.POP_STASH} should execute without error`, async function () { + await vscode.commands.executeCommand(COMMANDS.POP_STASH); + assert.ok(true, `${COMMANDS.POP_STASH} completed without error`); + }); }); diff --git a/test/unit/notifications/Notifications.test.ts b/test/unit/notifications/Notifications.test.ts index 729e8ef..7d3f308 100644 --- a/test/unit/notifications/Notifications.test.ts +++ b/test/unit/notifications/Notifications.test.ts @@ -1,5 +1,6 @@ /// import { describe, it, expect, beforeEach, vi } from "vitest"; +import * as vscode from "vscode"; import { Notifications } from "../../../src/notifications/Notifications"; import { Logger } from "../../../src/logging/Logger"; @@ -21,32 +22,135 @@ describe("Notifications", () => { vi.clearAllMocks(); }); - it("shows info messages", async () => { - const result = await Notifications.info("info message"); - expect(result).toBe("ok"); + describe("info", () => { + it("shows info messages without modal by default", async () => { + const result = await Notifications.info("info message"); + expect(result).toBe("ok"); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + "info message", + { modal: false }, + ); + }); + + it("shows info messages with modal when specified", async () => { + await Notifications.info("info message", true); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + "info message", + { modal: true }, + ); + }); + }); + + describe("success", () => { + it("shows success message with icon", async () => { + await Notifications.success("Operation successful"); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + "✓ Operation successful", + { modal: false }, + ); + }); + + it("shows success message with modal when specified", async () => { + await Notifications.success("Operation successful", true); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + "✓ Operation successful", + { modal: true }, + ); + }); }); - it("shows warning messages", async () => { - const result = await Notifications.warning("warning message"); - expect(result).toBe("warned"); + describe("warning", () => { + it("shows warning messages without modal by default", async () => { + const result = await Notifications.warning("warning message"); + expect(result).toBe("warned"); + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( + "warning message", + { modal: false }, + ); + }); + + it("shows warning messages with modal when specified", async () => { + await Notifications.warning("warning message", true); + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( + "warning message", + { modal: true }, + ); + }); + }); + + describe("error", () => { + it("shows error messages without modal by default", async () => { + await Notifications.error("error message"); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "error message", + { modal: false }, + ); + }); + + it("shows error messages with modal when specified", async () => { + await Notifications.error("error message", true); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "error message", + { modal: true }, + ); + }); }); - it("opens output when the user selects Show Output", async () => { - const showSpy = vi.spyOn(Logger, "show"); + describe("errorWithOutput", () => { + it("opens output when the user selects Show Output", async () => { + const showSpy = vi.spyOn(Logger, "show"); - await Notifications.errorWithOutput("error occurred"); + await Notifications.errorWithOutput("error occurred"); - expect(showSpy).toHaveBeenCalled(); + expect(showSpy).toHaveBeenCalled(); + }); + + it("does not open output when the user dismisses the error notification", async () => { + (vscode.window.showErrorMessage as any).mockResolvedValueOnce(undefined); + const showSpy = vi.spyOn(Logger, "show"); + + await Notifications.errorWithOutput("error occurred"); + + expect(showSpy).not.toHaveBeenCalled(); + }); + + it("supports modal option", async () => { + (vscode.window.showErrorMessage as any).mockResolvedValueOnce(undefined); + + await Notifications.errorWithOutput("error occurred", true); + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "error occurred", + { modal: true }, + "Show Output", + ); + }); }); - it("does not open output when the user dismisses the error notification", async () => { - const vscodeMock = await import("vscode"); - (vscodeMock.window.showErrorMessage as unknown as ReturnType).mockResolvedValue(undefined); + describe("choice", () => { + it("shows choice dialog with buttons and modal true by default", async () => { + (vscode.window.showWarningMessage as any).mockResolvedValueOnce("Yes"); + + const result = await Notifications.choice("Confirm?", ["Yes", "No"]); + + expect(result).toBe("Yes"); + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( + "Confirm?", + { modal: true }, + "Yes", + "No", + ); + }); - const showSpy = vi.spyOn(Logger, "show"); + it("returns selected button", async () => { + (vscode.window.showWarningMessage as any).mockResolvedValueOnce("No"); - await Notifications.errorWithOutput("error occurred"); + const result = await Notifications.choice("Choose", [ + "Option A", + "Option B", + ]); - expect(showSpy).not.toHaveBeenCalled(); + expect(result).toBe("No"); + }); }); }); diff --git a/test/unit/services/GitService.test.ts b/test/unit/services/GitService.test.ts index 8e9a653..c8762ab 100644 --- a/test/unit/services/GitService.test.ts +++ b/test/unit/services/GitService.test.ts @@ -51,6 +51,9 @@ const mockGit = { reset: vi.fn(), deleteLocalBranch: vi.fn(), raw: vi.fn(), + fetch: vi.fn(), + version: vi.fn(), + stash: vi.fn(), }; vi.mock("simple-git", () => ({ @@ -67,21 +70,27 @@ describe("GitService", () => { service = new GitService("/workspace/gops"); }); - it("parses ahead/behind values for local branches", async () => { - mockGit.branchLocal.mockResolvedValue({ + it("parses branch info for local branches", async () => { + mockGit.branch.mockResolvedValue({ all: ["main", "feature"], current: "main", branches: { - main: { label: "behind 3" }, - feature: { label: "ahead 2" }, + main: { label: "[origin/main: behind 3]" }, + feature: { label: "[origin/feature: ahead 2]" }, }, }); const branches = await service.getLocalBranches(); expect(branches).toEqual([ - { name: "main", current: true, ahead: 0, behind: 3 }, - { name: "feature", current: false, ahead: 2, behind: 0 }, + { name: "main", current: true, ahead: 0, behind: 3, hasUpstream: true }, + { + name: "feature", + current: false, + ahead: 2, + behind: 0, + hasUpstream: true, + }, ]); }); @@ -149,9 +158,13 @@ describe("GitService", () => { }); it("returns stash messages from stash list", async () => { - mockGit.stashList.mockResolvedValue({ all: [{ message: "WIP" }] } as any); + mockGit.stashList.mockResolvedValue({ + all: [{ message: "WIP" }], + }); + + const result = await service.getStash(); - expect(await service.getStash()).toEqual(["WIP"]); + expect(result).toEqual([{ ref: "stash@{0}", message: "WIP" }]); }); it("returns commit log entries", async () => { @@ -493,4 +506,200 @@ describe("GitService", () => { "Failed to rename branch old-branch. See details in output", ); }); + + it("logs and notifies on successful publishBranch", async () => { + mockGit.push.mockResolvedValue("ok"); + const infoSpy = vi.spyOn(Logger, "info"); + const notifySpy = vi.spyOn(Notifications, "info"); + + await service.publishBranch("feature/my-branch"); + + expect(mockGit.push).toHaveBeenCalledWith([ + "--set-upstream", + "origin", + "feature/my-branch", + ]); + expect(infoSpy).toHaveBeenCalledWith( + "Branch feature/my-branch published to origin", + ); + expect(notifySpy).toHaveBeenCalledWith( + "Branch feature/my-branch published to origin", + ); + }); + + it("logs error and rethrows when publishBranch fails", async () => { + const error = new Error("publish failed"); + mockGit.push.mockRejectedValue(error); + const errorSpy = vi.spyOn(Logger, "error"); + const notifySpy = vi.spyOn(Notifications, "errorWithOutput"); + + await expect(service.publishBranch("feature/my-branch")).rejects.toThrow( + error, + ); + expect(errorSpy).toHaveBeenCalledWith( + "Failed to publish branch feature/my-branch: publish failed", + ); + expect(notifySpy).toHaveBeenCalledWith( + "Failed to publish branch feature/my-branch. See details in output", + ); + }); + + it("logs and notifies on successful fetch", async () => { + mockGit.fetch.mockResolvedValue("ok"); + const infoSpy = vi.spyOn(Logger, "info"); + const notifySpy = vi.spyOn(Notifications, "info"); + + await service.fetch(); + + expect(mockGit.fetch).toHaveBeenCalled(); + expect(infoSpy).toHaveBeenCalledWith("Fetched latest changes"); + expect(notifySpy).toHaveBeenCalledWith("Fetched latest changes"); + }); + + it("logs error and rethrows when fetch fails", async () => { + const error = new Error("fetch failed"); + mockGit.fetch.mockRejectedValue(error); + const errorSpy = vi.spyOn(Logger, "error"); + const notifySpy = vi.spyOn(Notifications, "errorWithOutput"); + + await expect(service.fetch()).rejects.toThrow(error); + expect(errorSpy).toHaveBeenCalledWith( + "Failed to fetch changes: fetch failed", + ); + expect(notifySpy).toHaveBeenCalledWith( + "Failed to fetch changes. See details in output", + ); + }); + + describe("isGitAvailable", () => { + it("should return true when git is available", async () => { + mockGit.version.mockResolvedValue("2.40.0"); + + const result = await GitService.isGitAvailable(); + + expect(result).toBe(true); + }); + + it("should return false when git is not available", async () => { + mockGit.version.mockRejectedValue(new Error("git not found")); + + const result = await GitService.isGitAvailable(); + + expect(result).toBe(false); + }); + }); + + it("logs and notifies on successful popStash", async () => { + mockGit.stash.mockResolvedValue("ok"); + const infoSpy = vi.spyOn(Logger, "info"); + const notifySpy = vi.spyOn(Notifications, "info"); + + await service.popStash("stash@{0}"); + + expect(mockGit.stash).toHaveBeenCalledWith(["pop", "stash@{0}"]); + expect(infoSpy).toHaveBeenCalledWith("Popped stash stash@{0}"); + expect(notifySpy).toHaveBeenCalledWith("Popped stash stash@{0}"); + }); + + it("logs error and rethrows when popStash fails", async () => { + const error = new Error("pop failed"); + mockGit.stash.mockRejectedValue(error); + const errorSpy = vi.spyOn(Logger, "error"); + const notifySpy = vi.spyOn(Notifications, "errorWithOutput"); + + await expect(service.popStash("stash@{0}")).rejects.toThrow(error); + expect(errorSpy).toHaveBeenCalledWith( + "Failed to pop stash stash@{0}: pop failed", + ); + expect(notifySpy).toHaveBeenCalledWith( + "Failed to pop stash stash@{0}. See details in output", + ); + }); + + describe("stashChanges", () => { + it("logs and notifies on successful stashChanges", async () => { + mockGit.stash.mockResolvedValue("ok"); + const infoSpy = vi.spyOn(Logger, "info"); + const notifySpy = vi.spyOn(Notifications, "info"); + + await service.stashChanges(); + + expect(mockGit.stash).toHaveBeenCalledWith(); + expect(infoSpy).toHaveBeenCalledWith("Changes stashed successfully"); + expect(notifySpy).toHaveBeenCalledWith("Changes stashed successfully"); + }); + + it("logs error and rethrows when stashChanges fails", async () => { + const error = new Error("stash failed"); + mockGit.stash.mockRejectedValue(error); + const errorSpy = vi.spyOn(Logger, "error"); + const notifySpy = vi.spyOn(Notifications, "errorWithOutput"); + + await expect(service.stashChanges()).rejects.toThrow(error); + expect(errorSpy).toHaveBeenCalledWith( + "Failed to stash changes: stash failed", + ); + expect(notifySpy).toHaveBeenCalledWith( + "Failed to stash changes. See details in output", + ); + }); + }); + + describe("popStash", () => { + it("logs and notifies on successful popStash", async () => { + mockGit.stash.mockResolvedValue("ok"); + const infoSpy = vi.spyOn(Logger, "info"); + const notifySpy = vi.spyOn(Notifications, "info"); + + await service.popStash("stash@{0}"); + + expect(mockGit.stash).toHaveBeenCalledWith(["pop", "stash@{0}"]); + expect(infoSpy).toHaveBeenCalledWith("Popped stash stash@{0}"); + expect(notifySpy).toHaveBeenCalledWith("Popped stash stash@{0}"); + }); + + it("logs error and rethrows when popStash fails", async () => { + const error = new Error("pop failed"); + mockGit.stash.mockRejectedValue(error); + const errorSpy = vi.spyOn(Logger, "error"); + const notifySpy = vi.spyOn(Notifications, "errorWithOutput"); + + await expect(service.popStash("stash@{0}")).rejects.toThrow(error); + expect(errorSpy).toHaveBeenCalledWith( + "Failed to pop stash stash@{0}: pop failed", + ); + expect(notifySpy).toHaveBeenCalledWith( + "Failed to pop stash stash@{0}. See details in output", + ); + }); + }); + + describe("getStash", () => { + it("returns stashes with ref and message", async () => { + mockGit.stashList.mockResolvedValue({ + all: [ + { message: "WIP on main: 6079950 Delete .localenv" }, + { message: "WIP on feature: abc1234 Add new feature" }, + ], + }); + + const result = await service.getStash(); + + expect(result).toEqual([ + { ref: "stash@{0}", message: "WIP on main: 6079950 Delete .localenv" }, + { + ref: "stash@{1}", + message: "WIP on feature: abc1234 Add new feature", + }, + ]); + }); + + it("returns empty array when no stashes exist", async () => { + mockGit.stashList.mockResolvedValue({ all: [] }); + + const result = await service.getStash(); + + expect(result).toEqual([]); + }); + }); });