From 76d909ba55f9a5a9a6c96ff6b475968fe8d4f849 Mon Sep 17 00:00:00 2001 From: CodeMan X Date: Mon, 1 Jun 2026 20:26:02 +1000 Subject: [PATCH 1/7] updated changelog --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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 From 29f6bba11f14beaab65a28c39d74461e4c5844e8 Mon Sep 17 00:00:00 2001 From: CodeMan X Date: Mon, 1 Jun 2026 20:38:19 +1000 Subject: [PATCH 2/7] refactor(git): update showDiff to support staged files Update the type check in `showDiff` to allow `StagedFileNode` in addition to `ChangedFileNode`, ensuring staged changes can also be displayed in the diff view. --- src/commands/GitOperationsDelegate.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/commands/GitOperationsDelegate.ts b/src/commands/GitOperationsDelegate.ts index d64d109..607fa7c 100644 --- a/src/commands/GitOperationsDelegate.ts +++ b/src/commands/GitOperationsDelegate.ts @@ -112,7 +112,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; } From 183b4b6e57ab95d5f919e159b6ca9f2fcb1aa8b2 Mon Sep 17 00:00:00 2001 From: CodeMan X Date: Tue, 2 Jun 2026 21:32:24 +1000 Subject: [PATCH 3/7] feat(git): implement publish branch functionality Add the ability to publish local branches to origin. This includes: - Adding a `publishBranch` command and its corresponding implementation in `GitOperationsDelegate`. - Updating `GitService` to use `git branch -vv` to detect upstream tracking status. - Refactoring branch models to replace `AheadBehindModel` with `BranchInfoModel` which includes `hasUpstream` information. - Updating the Git tree view to show a "Publish Branch" action for local branches that do not have an upstream configured. - Updating command visibility logic in `package.json` to support new context values. --- package.json | 17 ++++++++++++++--- src/commands/CommandRegistrar.ts | 6 +++++- src/commands/Commands.ts | 1 + src/commands/GitOperationsDelegate.ts | 9 +++++++++ src/gopstree/ContextValue.ts | 21 +++++++++++++++++---- src/gopstree/TreeDataProvider.ts | 2 +- src/gopstree/nodes/LocalBranchNode.ts | 21 ++++++++++++++------- src/models/AheadBehindModel.ts | 4 ---- src/models/BranchInfoModel.ts | 5 +++++ src/models/LocalBranchModel.ts | 1 + src/services/GitService.ts | 24 +++++++++++++++++------- test/unit/services/GitService.test.ts | 12 ++++++------ 12 files changed, 90 insertions(+), 33 deletions(-) delete mode 100644 src/models/AheadBehindModel.ts create mode 100644 src/models/BranchInfoModel.ts diff --git a/package.json b/package.json index 2e1000e..04f31a5 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,11 @@ "command": "gops.showGitGraph", "title": "Show Git Graph", "icon": "$(graph)" + }, + { + "command": "gops.publishBranch", + "title": "Publish Branch", + "icon": "$(cloud-upload)" } ], "viewsContainers": { @@ -181,7 +186,7 @@ { "command": "gops.checkout", "title": "Checkout Branch", - "when": "view == gitOpsTreeview && viewItem == localBranches", + "when": "view == gitOpsTreeview && (viewItem == localBranches || viewItem == localBranches.noUpstream)", "group": "navigation" }, { @@ -211,14 +216,20 @@ { "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" } ] } diff --git a/src/commands/CommandRegistrar.ts b/src/commands/CommandRegistrar.ts index 5d4a3d4..01f02b4 100644 --- a/src/commands/CommandRegistrar.ts +++ b/src/commands/CommandRegistrar.ts @@ -41,7 +41,11 @@ 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), ); } diff --git a/src/commands/Commands.ts b/src/commands/Commands.ts index cb859f1..ebe40ab 100644 --- a/src/commands/Commands.ts +++ b/src/commands/Commands.ts @@ -15,4 +15,5 @@ export const COMMANDS = { UNSTAGE_ALL_FILES: "gops.unstageAllFiles", COMMIT: "gops.commit", SHOW_GIT_GRAPH: "gops.showGitGraph", + PUBLISH_BRANCH: "gops.publishBranch", } as const; diff --git a/src/commands/GitOperationsDelegate.ts b/src/commands/GitOperationsDelegate.ts index 607fa7c..1ee3e18 100644 --- a/src/commands/GitOperationsDelegate.ts +++ b/src/commands/GitOperationsDelegate.ts @@ -183,4 +183,13 @@ 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(); + } } 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..c9f9ea0 100644 --- a/src/gopstree/TreeDataProvider.ts +++ b/src/gopstree/TreeDataProvider.ts @@ -73,7 +73,7 @@ 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; }); 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/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/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/services/GitService.ts b/src/services/GitService.ts index cc7e72e..94bc510 100644 --- a/src/services/GitService.ts +++ b/src/services/GitService.ts @@ -12,7 +12,7 @@ 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"; export class GitService { private git: SimpleGit; @@ -102,15 +102,15 @@ 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), }; }); } @@ -222,6 +222,14 @@ export class GitService { `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}`, + ); + } // #endregion getRepoName(): string { @@ -261,13 +269,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); return { ahead: aheadMatch ? parseInt(aheadMatch[1], 10) : 0, behind: behindMatch ? parseInt(behindMatch[1], 10) : 0, + hasUpstream, }; } } diff --git a/test/unit/services/GitService.test.ts b/test/unit/services/GitService.test.ts index 8e9a653..ae051cc 100644 --- a/test/unit/services/GitService.test.ts +++ b/test/unit/services/GitService.test.ts @@ -67,21 +67,21 @@ 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 }, ]); }); From 64ce8dbc90bf8a554c4ca3cd43d9e501a0fff3f3 Mon Sep 17 00:00:00 2001 From: CodeMan X Date: Thu, 4 Jun 2026 20:50:49 +1000 Subject: [PATCH 4/7] feat(git): refresh remote branches after publishing Update the publish branch command to refresh the remote branches node in the tree view, ensuring the UI stays in sync with the remote state. Also update `refreshRemoteBranchesNode` to be asynchronous. Includes integration and unit tests for the publish branch functionality. --- src/commands/GitOperationsDelegate.ts | 1 + src/gopstree/TreeDataProvider.ts | 2 +- test/integration/branch.test.ts | 10 +++++++- test/unit/services/GitService.test.ts | 37 +++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/commands/GitOperationsDelegate.ts b/src/commands/GitOperationsDelegate.ts index 1ee3e18..fc0f8eb 100644 --- a/src/commands/GitOperationsDelegate.ts +++ b/src/commands/GitOperationsDelegate.ts @@ -191,5 +191,6 @@ export class GitOperationsDelegate { await this.gitService.publishBranch(node.branchName); await this.treeDataProvider.refreshLocalBranchesNode(); + await this.treeDataProvider.refreshRemoteBranchesNode(); } } diff --git a/src/gopstree/TreeDataProvider.ts b/src/gopstree/TreeDataProvider.ts index c9f9ea0..c3762bc 100644 --- a/src/gopstree/TreeDataProvider.ts +++ b/src/gopstree/TreeDataProvider.ts @@ -232,7 +232,7 @@ export class TreeDataProvider implements vscode.TreeDataProvider { this._onDidChangeTreeData.fire(this.localBranchesNode); } - refreshRemoteBranchesNode(): void { + async refreshRemoteBranchesNode(): Promise { if (!this.remoteBranchesNode) { return; } 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/unit/services/GitService.test.ts b/test/unit/services/GitService.test.ts index ae051cc..e38d8ae 100644 --- a/test/unit/services/GitService.test.ts +++ b/test/unit/services/GitService.test.ts @@ -493,4 +493,41 @@ 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", + ); +}); }); From 96bdfd478f9a9c47562a2f54b69ba8869dbda44c Mon Sep 17 00:00:00 2001 From: CodeMan X Date: Fri, 5 Jun 2026 19:36:49 +1000 Subject: [PATCH 5/7] feat(git): implement fetch command and enhance notification system Add support for the `git fetch --prune` command to the extension, allowing users to update remote tracking branches. This includes new commands in the tree view and integration tests. Additionally, refactor the `Notifications` service to support modal dialogs and success messages, and improve the extension activation process by verifying Git availability on startup. - Implement `gops.fetch` command and service method - Add `git fetch --prune` functionality - Update `Notifications` to support modal options and success icons - Add Git availability check during extension activation - Improve branch deletion safety by preventing deletion of the current branch - Update README with new feature descriptions - Add unit and integration tests for new functionality --- README.md | 47 +++--- package.json | 10 ++ src/commands/CommandRegistrar.ts | 1 + src/commands/Commands.ts | 1 + src/commands/GitOperationsDelegate.ts | 20 ++- src/extension.ts | 16 ++- src/notifications/Notifications.ts | 42 +++++- src/services/GitService.ts | 20 ++- test/integration/commands.test.ts | 8 ++ test/unit/notifications/Notifications.test.ts | 136 +++++++++++++++--- test/unit/services/GitService.test.ts | 121 +++++++++++----- 11 files changed, 340 insertions(+), 82 deletions(-) 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/package.json b/package.json index 04f31a5..c5e9e4b 100644 --- a/package.json +++ b/package.json @@ -128,6 +128,11 @@ "command": "gops.publishBranch", "title": "Publish Branch", "icon": "$(cloud-upload)" + }, + { + "command": "gops.fetch", + "title": "Fetch", + "icon": "$(cloud-download)" } ], "viewsContainers": { @@ -174,6 +179,11 @@ "command": "gops.branch.current", "when": "view == gitOpsTreeview", "group": "navigation" + }, + { + "command": "gops.fetch", + "when": "view == gitOpsTreeview", + "group": "navigation" } ], "view/item/context": [ diff --git a/src/commands/CommandRegistrar.ts b/src/commands/CommandRegistrar.ts index 01f02b4..f0ab18f 100644 --- a/src/commands/CommandRegistrar.ts +++ b/src/commands/CommandRegistrar.ts @@ -47,6 +47,7 @@ export class CommandRegistrar { this.register(COMMANDS.PUBLISH_BRANCH, (node) => this.delegate.publishBranch(node), ); + this.register(COMMANDS.FETCH, () => this.delegate.fetch()); } private register( diff --git a/src/commands/Commands.ts b/src/commands/Commands.ts index ebe40ab..3836957 100644 --- a/src/commands/Commands.ts +++ b/src/commands/Commands.ts @@ -16,4 +16,5 @@ export const COMMANDS = { COMMIT: "gops.commit", SHOW_GIT_GRAPH: "gops.showGitGraph", PUBLISH_BRANCH: "gops.publishBranch", + FETCH: "gops.fetch", } as const; diff --git a/src/commands/GitOperationsDelegate.ts b/src/commands/GitOperationsDelegate.ts index fc0f8eb..0b936ce 100644 --- a/src/commands/GitOperationsDelegate.ts +++ b/src/commands/GitOperationsDelegate.ts @@ -6,6 +6,8 @@ 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"; export class GitOperationsDelegate { constructor( @@ -30,14 +32,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") { @@ -193,4 +199,10 @@ export class GitOperationsDelegate { await this.treeDataProvider.refreshLocalBranchesNode(); await this.treeDataProvider.refreshRemoteBranchesNode(); } + + async fetch(): Promise { + await this.gitService.fetch(); + await this.treeDataProvider.refreshLocalBranchesNode(); + this.treeDataProvider.refreshRemoteBranchesNode(); + } } 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/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 94bc510..c468b64 100644 --- a/src/services/GitService.ts +++ b/src/services/GitService.ts @@ -31,6 +31,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(); } @@ -230,6 +240,14 @@ export class GitService { `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 { @@ -272,7 +290,7 @@ export class GitService { private parseBranchInfo(label: string): BranchInfoModel { const aheadMatch = label.match(/ahead (\d+)/); const behindMatch = label.match(/behind (\d+)/); - const hasUpstream = /^\[[^\]]+\]/.test(label); + const hasUpstream = /^\[[^\]]+\]/.test(label) && !/: gone/.test(label); return { ahead: aheadMatch ? parseInt(aheadMatch[1], 10) : 0, diff --git a/test/integration/commands.test.ts b/test/integration/commands.test.ts index 8d7112c..2cd21f2 100644 --- a/test/integration/commands.test.ts +++ b/test/integration/commands.test.ts @@ -29,6 +29,9 @@ suite("Commands", function () { COMMANDS.UNSTAGE_FILE, COMMANDS.UNSTAGE_ALL_FILES, COMMANDS.COMMIT, + COMMANDS.SHOW_GIT_GRAPH, + COMMANDS.PUBLISH_BRANCH, + COMMANDS.FETCH, ]; for (const command of expectedCommands) { @@ -67,4 +70,9 @@ 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"); + }); }); 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 e38d8ae..d282d6d 100644 --- a/test/unit/services/GitService.test.ts +++ b/test/unit/services/GitService.test.ts @@ -51,6 +51,8 @@ const mockGit = { reset: vi.fn(), deleteLocalBranch: vi.fn(), raw: vi.fn(), + fetch: vi.fn(), + version: vi.fn(), }; vi.mock("simple-git", () => ({ @@ -81,7 +83,13 @@ describe("GitService", () => { expect(branches).toEqual([ { name: "main", current: true, ahead: 0, behind: 3, hasUpstream: true }, - { name: "feature", current: false, ahead: 2, behind: 0, hasUpstream: true }, + { + name: "feature", + current: false, + ahead: 2, + behind: 0, + hasUpstream: true, + }, ]); }); @@ -494,40 +502,85 @@ describe("GitService", () => { ); }); -it("logs and notifies on successful publishBranch", async () => { - mockGit.push.mockResolvedValue("ok"); - const infoSpy = vi.spyOn(Logger, "info"); - const notifySpy = vi.spyOn(Notifications, "info"); + 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"); + 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", - ); -}); + 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 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); + }); + }); }); From 55119a83ecff175522d0d098c6cb832e5b2addd9 Mon Sep 17 00:00:00 2001 From: CodeMan X Date: Fri, 5 Jun 2026 20:31:10 +1000 Subject: [PATCH 6/7] feat(git): add stash and pop stash functionality Implement support for Git stash operations, including stashing current changes and popping specific stashes from the stash list. This includes new commands, tree view integration via `StashNode`, and updated service methods. - Add `gops.stashChanges` and `gops.popStash` commands - Implement `stashChanges` and `popStash` in `GitService` - Introduce `StashNode` for enhanced tree view representation - Update `TreeDataProvider` to include stash entries in the view - Add integration and unit tests for stash operations - Update `package.json` with new command definitions and view context visibility --- package.json | 20 +++++ src/commands/CommandRegistrar.ts | 2 + src/commands/Commands.ts | 2 + src/commands/GitOperationsDelegate.ts | 15 ++++ src/gopstree/TreeDataProvider.ts | 22 +++-- src/gopstree/nodes/StashNode.ts | 23 +++++ src/services/GitService.ts | 23 ++++- test/integration/commands.test.ts | 16 ++++ test/unit/services/GitService.test.ts | 123 +++++++++++++++++++++++++- 9 files changed, 233 insertions(+), 13 deletions(-) create mode 100644 src/gopstree/nodes/StashNode.ts diff --git a/package.json b/package.json index c5e9e4b..bba99c1 100644 --- a/package.json +++ b/package.json @@ -133,6 +133,16 @@ "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": { @@ -184,6 +194,11 @@ "command": "gops.fetch", "when": "view == gitOpsTreeview", "group": "navigation" + }, + { + "command": "gops.stashChanges", + "when": "view == gitOpsTreeview && gops.hasChangedFiles == true", + "group": "navigation" } ], "view/item/context": [ @@ -240,6 +255,11 @@ "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 f0ab18f..54e5e15 100644 --- a/src/commands/CommandRegistrar.ts +++ b/src/commands/CommandRegistrar.ts @@ -48,6 +48,8 @@ export class CommandRegistrar { 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 3836957..592d0db 100644 --- a/src/commands/Commands.ts +++ b/src/commands/Commands.ts @@ -17,4 +17,6 @@ export const COMMANDS = { 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 0b936ce..a954f86 100644 --- a/src/commands/GitOperationsDelegate.ts +++ b/src/commands/GitOperationsDelegate.ts @@ -8,6 +8,7 @@ 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( @@ -205,4 +206,18 @@ export class GitOperationsDelegate { 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/gopstree/TreeDataProvider.ts b/src/gopstree/TreeDataProvider.ts index c3762bc..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, b.hasUpstream); + 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 { 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/services/GitService.ts b/src/services/GitService.ts index c468b64..25cad97 100644 --- a/src/services/GitService.ts +++ b/src/services/GitService.ts @@ -125,6 +125,22 @@ export class GitService { }); } + 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(); } @@ -143,9 +159,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 { diff --git a/test/integration/commands.test.ts b/test/integration/commands.test.ts index 2cd21f2..0ec1891 100644 --- a/test/integration/commands.test.ts +++ b/test/integration/commands.test.ts @@ -32,6 +32,7 @@ suite("Commands", function () { COMMANDS.SHOW_GIT_GRAPH, COMMANDS.PUBLISH_BRANCH, COMMANDS.FETCH, + COMMANDS.POP_STASH, ]; for (const command of expectedCommands) { @@ -75,4 +76,19 @@ suite("Commands", 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/services/GitService.test.ts b/test/unit/services/GitService.test.ts index d282d6d..c8762ab 100644 --- a/test/unit/services/GitService.test.ts +++ b/test/unit/services/GitService.test.ts @@ -53,6 +53,7 @@ const mockGit = { raw: vi.fn(), fetch: vi.fn(), version: vi.fn(), + stash: vi.fn(), }; vi.mock("simple-git", () => ({ @@ -157,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 () => { @@ -583,4 +588,118 @@ describe("GitService", () => { 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([]); + }); + }); }); From 6f0145262a6e4675efa1af61ad9a1b151230f1af Mon Sep 17 00:00:00 2001 From: CodeMan X Date: Fri, 5 Jun 2026 20:48:46 +1000 Subject: [PATCH 7/7] feat(ui): enhance git graph visualization with merge info and refs Improve the Git Graph webview by displaying merge commit indicators and branch references. This update includes data model enhancements to support more detailed commit information and updated CSS for styling reference badges. - Update `GitCommitModel` to include `isMergeCommit` and `refs` - Enhance `GitService.getBranchCommits` to fetch detailed commit metadata using custom git log formats - Add `[MERGE]` prefix to merge commit messages in the webview - Implement visual badges for commit references in the graph table - Add styling for `.refs` badges in `gitGraph.css` --- media/gitGraph.css | 10 ++++++++++ src/gopswebpanel/GitGraphWebview.ts | 21 +++++++++++++------- src/models/GitCommitModel.ts | 2 ++ src/services/GitService.ts | 30 +++++++++++++++++------------ 4 files changed, 44 insertions(+), 19 deletions(-) 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/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/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/services/GitService.ts b/src/services/GitService.ts index 25cad97..4a64ddd 100644 --- a/src/services/GitService.ts +++ b/src/services/GitService.ts @@ -13,6 +13,7 @@ import { Notifications } from "../notifications/Notifications"; import { LocalBranchModel } from "../models/LocalBranchModel"; import { RemoteBranchModel } from "../models/RemoteBranchModel"; import { BranchInfoModel } from "../models/BranchInfoModel"; +import { GitCommitModel } from "../models/GitCommitModel"; export class GitService { private git: SimpleGit; @@ -229,22 +230,27 @@ 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}`,