diff --git a/README.md b/README.md index 7d8db4f..ed8cfc3 100644 --- a/README.md +++ b/README.md @@ -44,17 +44,7 @@ Calling out known issues can help limit users opening duplicate issues against y ## Release Notes -Users appreciate release notes as you update your extension. - -### 0.0.1 - -Initial release of Gops - -## Following extension guidelines - -Ensure that you've read through the extensions guidelines and follow the best practices for creating your extension. - -- [Extension Guidelines](https://code.visualstudio.com/api/references/extension-guidelines) +Refer to CHANGELOG.md ## Build Process diff --git a/package.json b/package.json index 80b992b..d96b9f6 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,26 @@ "command": "gops.showDiff", "title": "Show Diff", "icon": "$(diff)" + }, + { + "command": "gops.stageFile", + "title": "Stage File", + "icon": "$(add)" + }, + { + "command": "gops.unstageFile", + "title": "Unstage File", + "icon": "$(remove)" + }, + { + "command": "gops.commit", + "title": "Commit", + "icon": "$(save)" + }, + { + "command": "gops.unstageAllFiles", + "title": "Unstage All Files", + "icon": "$(collapse-all)" } ], "viewsContainers": { @@ -119,6 +139,11 @@ }, "menus": { "view/title": [ + { + "command": "gops.commit", + "when": "view == gitOpsTreeview && gops.hasStagedFiles == true", + "group": "navigation" + }, { "command": "gops.refresh", "when": "view == gitOpsTreeview", @@ -143,13 +168,33 @@ "view/item/context": [ { "command": "gops.branch", + "title": "New Branch", "when": "view == gitOpsTreeview && (viewItem == localBranches || viewItem == localBranches.current)", "group": "navigation" }, { "command": "gops.checkout", + "title": "Checkout Branch", "when": "view == gitOpsTreeview && viewItem == localBranches", "group": "navigation" + }, + { + "command": "gops.stageFile", + "title": "Stage File", + "when": "view == gitOpsTreeview && viewItem == changedFile", + "group": "inline" + }, + { + "command": "gops.unstageFile", + "title": "Unstage File", + "when": "view == gitOpsTreeview && viewItem == stagedFile", + "group": "inline" + }, + { + "command": "gops.unstageAllFiles", + "title": "Unstage All Files", + "when": "view == gitOpsTreeview && viewItem == stagedChangesSection", + "group": "inline" } ] } @@ -170,7 +215,7 @@ "check:types": "tsc --noEmit", "lint": "eslint src", "test:unit": "vitest run", - "test:integration": "vscode-test", + "test:integration": "npm run compile && npm run compile:tests && vscode-test", "coverage:unit": "vitest run --coverage --coverage.reporter=lcov --coverage.reportsDirectory=coverage/unit", "coverage:integration": "vscode-test --coverage --coverage-reporter lcov --coverage-output ./coverage/integration" }, diff --git a/src/commands/CommandRegistrar.ts b/src/commands/CommandRegistrar.ts index a702303..fda0a12 100644 --- a/src/commands/CommandRegistrar.ts +++ b/src/commands/CommandRegistrar.ts @@ -1,122 +1,43 @@ import * as vscode from "vscode"; -import { TreeDataProvider } from "../gopstree/TreeDataProvider"; -import { GitService } from "../services/GitService"; import { COMMANDS } from "./Commands"; -import { GitTreeNode } from "../gopstree/types"; +import { GitOperationsDelegate } from "./GitOperationsDelegate"; import { Logger } from "../logging/Logger"; -import { DiffService } from "../services/DiffService"; -import { ChangedFileNode } from "../gopstree/nodes/ChangedFileNode"; export class CommandRegistrar { constructor( private context: vscode.ExtensionContext, - private treeDataProvider: TreeDataProvider, - private gitService: GitService, - private diffService: DiffService, + private delegate: GitOperationsDelegate, ) {} registerAll() { - this.register(COMMANDS.REFRESH, () => { - console.debug(`executed command: ${COMMANDS.REFRESH}`); - this.treeDataProvider.refresh(); - }); - - this.register(COMMANDS.CHECKOUT_BRANCH, async (node: GitTreeNode) => { - console.debug(`executed command: ${COMMANDS.CHECKOUT_BRANCH}`); - if (node && "branchName" in node) { - await this.gitService.checkout(node.branchName); - this.treeDataProvider.refresh(node.parent); - } - }); - - this.register(COMMANDS.DELETE_BRANCH, async (node: GitTreeNode) => { - console.debug(`executed command: ${COMMANDS.DELETE_BRANCH}`); - //this.gitService.deleteBranch(node); - }); - - this.register(COMMANDS.RENAME_BRANCH, async (node: GitTreeNode) => { - console.debug(`executed command: ${COMMANDS.RENAME_BRANCH}`); - //this.gitService.renameBranch(node); - }); - - this.register(COMMANDS.PUSH, async () => { - console.debug(`executed command: ${COMMANDS.PUSH}`); - await this.gitService.push(); - }); - - this.register(COMMANDS.PULL, async () => { - console.debug(`executed command: ${COMMANDS.PULL}`); - await this.gitService.pull(); - }); - - this.register(COMMANDS.CREATE_BRANCH_FROM_CURRENT, async (node: GitTreeNode) => { - console.debug(`executed command: ${COMMANDS.CREATE_BRANCH_FROM_CURRENT}`); - const branchName: string | undefined = await vscode.window.showInputBox({ - prompt: "Enter new branch name", - placeHolder: "feature/my-new-feature", - ignoreFocusOut: true, - }); - - if (!branchName) { - return; - } - - await this.gitService.checkoutLocalBranch(branchName); - if (node?.parent) { - this.treeDataProvider.refresh(node.parent); - } - }); - - this.register( - COMMANDS.CREATE_BRANCH, - async (node: GitTreeNode) => { - console.debug( - `executed command: ${COMMANDS.CREATE_BRANCH}`, - ); - if (!node || !("branchName" in node)) { - return; - } - - const baseBranch = node?.branchName; - const branchName: string | undefined = await vscode.window.showInputBox( - { - prompt: `Enter new branch name to create from ${baseBranch}`, - placeHolder: "feature/my-new-feature", - ignoreFocusOut: true, - }, - ); - - if (!branchName) { - return; - } - - await this.gitService.checkoutBranch(branchName, baseBranch); - if (node?.parent) { - this.treeDataProvider.refresh(node.parent); - } - }, + this.register(COMMANDS.REFRESH, () => this.delegate.refresh()); + this.register(COMMANDS.CHECKOUT_BRANCH, (node) => + this.delegate.checkoutBranch(node), ); - - this.register(COMMANDS.SHOW_DIFF, async (node: GitTreeNode) => { - console.debug(`executed command: ${COMMANDS.SHOW_DIFF}`); - if (!node || !(node instanceof ChangedFileNode) || !node.fileName) { - return; - } - const repoPath = this.gitService.getRepoPath(); - - await this.diffService.openDiff({ - left: { - repositoryPath: repoPath, - fileName: node.fileName, - ref: "HEAD", - }, - right: { - repositoryPath: repoPath, - fileName: node.fileName, - }, - title: `Diff: ${node.fileName}`, - }); - }); + this.register(COMMANDS.DELETE_BRANCH, (node) => + this.delegate.deleteBranch(node), + ); + this.register(COMMANDS.RENAME_BRANCH, (node) => + this.delegate.renameBranch(node), + ); + this.register(COMMANDS.PUSH, () => this.delegate.push()); + this.register(COMMANDS.PULL, () => this.delegate.pull()); + this.register(COMMANDS.CREATE_BRANCH_FROM_CURRENT, (node) => + this.delegate.createBranchFromCurrent(node), + ); + this.register(COMMANDS.CREATE_BRANCH, (node) => + this.delegate.createBranchFrom(node), + ); + this.register(COMMANDS.SHOW_DIFF, (node) => this.delegate.showDiff(node)); + this.register(COMMANDS.STAGE_FILE, (node) => this.delegate.stageFile(node)); + this.register(COMMANDS.UNSTAGE_FILE, (node) => + this.delegate.unstageFile(node), + ); + this.register(COMMANDS.UNSTAGE_ALL_FILES, () => + this.delegate.unstageAllFiles(), + ); + this.register(COMMANDS.COMMIT, () => this.delegate.commit()); + this.register(COMMANDS.CREATE_TAG, () => this.delegate.createTag()); } private register( @@ -135,7 +56,6 @@ export class CommandRegistrar { } }, ); - this.context.subscriptions.push(disposable); } } diff --git a/src/commands/Commands.ts b/src/commands/Commands.ts index d97f9d5..986fa62 100644 --- a/src/commands/Commands.ts +++ b/src/commands/Commands.ts @@ -9,4 +9,8 @@ export const COMMANDS = { CREATE_BRANCH: "gops.branch", CREATE_TAG: "gops.tag", SHOW_DIFF: "gops.showDiff", + STAGE_FILE: "gops.stageFile", + UNSTAGE_FILE: "gops.unstageFile", + UNSTAGE_ALL_FILES: "gops.unstageAllFiles", + COMMIT: "gops.commit", } as const; diff --git a/src/commands/GitOperationsDelegate.ts b/src/commands/GitOperationsDelegate.ts new file mode 100644 index 0000000..8f977e4 --- /dev/null +++ b/src/commands/GitOperationsDelegate.ts @@ -0,0 +1,145 @@ +import * as vscode from "vscode"; +import { GitService } from "../services/GitService"; +import { DiffService } from "../services/DiffService"; +import { TreeDataProvider } from "../gopstree/TreeDataProvider"; +import { GitTreeNode } from "../gopstree/types"; +import { ChangedFileNode } from "../gopstree/nodes/ChangedFileNode"; +import { StagedFileNode } from "../gopstree/nodes/StagedFileNode"; + +export class GitOperationsDelegate { + constructor( + private readonly gitService: GitService, + private readonly diffService: DiffService, + private readonly treeDataProvider: TreeDataProvider, + private readonly treeView: vscode.TreeView, + ) {} + + refresh(): void { + this.treeDataProvider.refresh(); + } + + async checkoutBranch(node: GitTreeNode): Promise { + if (!node || !("branchName" in node)) { + return; + } + + await this.gitService.checkout(node.branchName); + await this.treeDataProvider.refreshRootNode(); + this.treeDataProvider.refreshLocalBranchesNode(); + } + + async deleteBranch(node: GitTreeNode): Promise { + if (!node || !("branchName" in node)) { + return; + } + // TODO: implement + } + + async renameBranch(node: GitTreeNode): Promise { + if (!node || !("branchName" in node)) { + return; + } + // TODO: implement + } + + async push(): Promise { + await this.gitService.push(); + } + + async pull(): Promise { + await this.gitService.pull(); + await this.treeDataProvider.refreshRootNode(); + this.treeDataProvider.refreshLocalBranchesNode(); + this.treeDataProvider.refreshRemoteBranchesNode(); + } + + async createBranchFromCurrent(node: GitTreeNode): Promise { + const branchName = await vscode.window.showInputBox({ + prompt: "Enter new branch name", + placeHolder: "feature/my-new-feature", + ignoreFocusOut: true, + }); + if (!branchName) { + return; + } + + await this.gitService.checkoutLocalBranch(branchName); + this.treeDataProvider.refreshLocalBranchesNode(); + } + + async createBranchFrom(node: GitTreeNode): Promise { + if (!node || !("branchName" in node)) { + return; + } + + const branchName = await vscode.window.showInputBox({ + prompt: `Enter new branch name to create from ${node.branchName}`, + placeHolder: "feature/my-new-feature", + ignoreFocusOut: true, + }); + if (!branchName) { + return; + } + + await this.gitService.checkoutBranch(branchName, node.branchName); + this.treeDataProvider.refreshLocalBranchesNode(); + } + + async showDiff(node: GitTreeNode): Promise { + if (!node || !(node instanceof ChangedFileNode) || !node.fileName) { + return; + } + + const repoPath = this.gitService.getRepoPath(); + await this.diffService.openDiff({ + left: { repositoryPath: repoPath, fileName: node.fileName, ref: "HEAD" }, + right: { repositoryPath: repoPath, fileName: node.fileName }, + title: `Diff: ${node.fileName}`, + }); + } + + async stageFile(node: GitTreeNode): Promise { + if (!node || !(node instanceof ChangedFileNode) || !node.fileName) { + return; + } + + await this.gitService.stageFile(node.fileName); + await this.treeDataProvider.refreshChangesNode(); + await this.treeDataProvider.refreshStagedNode(); + } + + async unstageFile(node: GitTreeNode): Promise { + if (!node || !(node instanceof StagedFileNode) || !node.fileName) { + return; + } + + await this.gitService.unstageFile(node.fileName); + await this.treeDataProvider.refreshChangesNode(); + await this.treeDataProvider.refreshStagedNode(); + } + + async unstageAllFiles(): Promise { + await this.gitService.unstageAllFiles(); + await this.treeDataProvider.refreshChangesNode(); + await this.treeDataProvider.refreshStagedNode(); + } + + async commit(): Promise { + const message = await vscode.window.showInputBox({ + prompt: "Enter commit message", + placeHolder: "feat: my changes", + ignoreFocusOut: true, + }); + if (!message) { + return; + } + + await this.gitService.commit(message); + this.treeDataProvider.refreshChangesNode(); + await this.treeDataProvider.refreshStagedNode(); + } + + async createTag(): Promise { + // TODO: implement + } +} diff --git a/src/constants/Constants.ts b/src/constants/Constants.ts index f3806d5..f6bd920 100644 --- a/src/constants/Constants.ts +++ b/src/constants/Constants.ts @@ -3,6 +3,7 @@ export class Constants { static readonly LOCAL_BRANCHES_LABEL = 'Local Branches'; static readonly REMOTE_BRANCHES_LABEL = 'Remote Branches'; static readonly CHANGES_LABEL = 'Changes'; + static readonly STAGED_LABEL = 'Staged Changes'; static readonly TAGS_LABEL = 'Tags'; static readonly STASH_LABEL = 'Stash'; diff --git a/src/extension.ts b/src/extension.ts index 5b904a8..02f3bd1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,28 +1,40 @@ import * as vscode from "vscode"; -import { TreeDataProvider } from "./gopstree/TreeDataProvider"; -import { GitService } from "./services/GitService"; import { CommandRegistrar } from "./commands/CommandRegistrar"; -import { DiffService } from "./services/DiffService"; +import { GitOperationsDelegate } from "./commands/GitOperationsDelegate"; +import { GitService } from "./services/GitService"; import { FileService } from "./services/FileService"; +import { DiffService } from "./services/DiffService"; +import { TreeDataProvider } from "./gopstree/TreeDataProvider"; export function activate(context: vscode.ExtensionContext) { const gitService = new GitService(); const fileService = new FileService(context.globalStorageUri.fsPath); const diffService = new DiffService(fileService, gitService); const treeDataProvider = new TreeDataProvider(gitService); - const treeView = vscode.window.createTreeView("gitOpsTreeview", { treeDataProvider }); - - // Register commands - const registrar = new CommandRegistrar( - context, + const treeView = vscode.window.createTreeView("gitOpsTreeview", { treeDataProvider, + }); + + const delegate = new GitOperationsDelegate( gitService, diffService, + treeDataProvider, + treeView, ); + const registrar = new CommandRegistrar(context, delegate); registrar.registerAll(); - context.subscriptions.push(treeView); - console.log("Gops extension activated."); -} + const onSave = vscode.workspace.onDidSaveTextDocument(() => { + treeDataProvider.refreshChangesNode(); + treeDataProvider.refreshStagedNode(); + }); -export function deactivate() {} + const gitWatcher = vscode.workspace.createFileSystemWatcher("**/.git/index"); + gitWatcher.onDidChange(() => { + 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 b584d9f..87ba805 100644 --- a/src/gopstree/ContextValue.ts +++ b/src/gopstree/ContextValue.ts @@ -1,6 +1,7 @@ export enum ContextValue { Repository = "repository", - Changes = "changes", + Changes = "changedFile", + StagedChanges = "stagedFile", LocalBranches = "localBranches", LocalBranchesCurrent = "localBranches.current", RemoteBranches = "remoteBranches", @@ -8,4 +9,11 @@ export enum ContextValue { File = "file", Stash = "stash", Commit = "commit", + LocalBranchesSection = "localBranchesSection", + RemoteBranchesSection = "remoteBranchesSection", + ChangesSection = "changesSection", + StagedChangesSection = "stagedChangesSection", + StagedChangesSectionEmpty = "stagedChangesSectionEmpty", + TagsSection = "tagsSection", + StashSection = "stashSection", } diff --git a/src/gopstree/TreeDataProvider.ts b/src/gopstree/TreeDataProvider.ts index e896922..63b7b19 100644 --- a/src/gopstree/TreeDataProvider.ts +++ b/src/gopstree/TreeDataProvider.ts @@ -9,6 +9,14 @@ import { Constants } from "../constants/Constants"; import { GitTreeNode } from "./types"; import { Notifications } from "../notifications/Notifications"; import { ChangedFileNode } from "./nodes/ChangedFileNode"; +import { StagedFileNode } from "./nodes/StagedFileNode"; +import { LocalBranchesSection } from "./nodes/LocalBranchesSection"; +import { RemoteBranchesSection } from "./nodes/RemoteBranchesSection"; +import { ChangesSection } from "./nodes/ChangesSection"; +import { StagedChangesSection } from "./nodes/StagedChangesSection"; +import { TagsSection } from "./nodes/TagsSection"; +import { StashSection } from "./nodes/StashSection"; +import { ContextValue } from "./ContextValue"; export class TreeDataProvider implements vscode.TreeDataProvider { private _onDidChangeTreeData = new vscode.EventEmitter< @@ -16,6 +24,13 @@ export class TreeDataProvider implements vscode.TreeDataProvider { >(); readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + private rootNode: RepositoryNode | undefined; + private localBranchesNode: LocalBranchesSection | undefined; + private remoteBranchesNode: RemoteBranchesSection | undefined; + private changesNode: ChangesSection | undefined; + private stagedNode: StagedChangesSection | undefined; + private tagsNode: TagsSection | undefined; + private stashNode: StashSection | undefined; constructor(private readonly gitService: GitService) {} @@ -28,37 +43,40 @@ export class TreeDataProvider implements vscode.TreeDataProvider { if (!element) { const repoName = this.gitService.getRepoName(); const currentBranch = await this.gitService.getCurrentBranch(); - return [new RepositoryNode(repoName, currentBranch)]; + this.rootNode = new RepositoryNode(repoName, currentBranch); + return [this.rootNode]; } //Routing based on node type switch (element.type) { case NodeType.Repository: - return this.getRepositoryChildren(); + return await this.getRepositoryChildren(); case NodeType.Local: - return this.getLocalBranches(element); + return await this.getLocalBranches(element); case NodeType.Remote: - return this.getRemoteBranches(); + return await this.getRemoteBranches(); case NodeType.Changes: - return this.getChanges(); + return await this.getChanges(); + case NodeType.StagedChanges: + return await this.getStagedChanges(); case NodeType.Tags: - return this.getTags(); + return await this.getTags(); case NodeType.Stash: - return this.getStash(); + return await this.getStash(); default: return []; } } - private async getLocalBranches(parent: TreeItemModel): Promise { + private async getLocalBranches( + parent: TreeItemModel, + ): Promise { const branches = await this.gitService.getLocalBranches(); - const allLocalBranches = branches.map( - (b) => { - const node = new LocalBranchNode(b.name, b.current, b.ahead, b.behind); - node.parent = parent; - return node; - } - ); + const allLocalBranches = branches.map((b) => { + const node = new LocalBranchNode(b.name, b.current, b.ahead, b.behind); + node.parent = parent; + return node; + }); for (const branch of allLocalBranches) { console.debug(branch.toString()); } @@ -73,71 +91,102 @@ export class TreeDataProvider implements vscode.TreeDataProvider { } private async getChanges(): Promise { - const status = await this.gitService.getStatus(); - const changedFiles = [ - ...status.modified, - ...status.not_added, - ...status.created, - ...status.deleted, - ...status.renamed.map((f) => f.to), - ]; - + const changedFiles = await this.gitService.getChangedFiles(); const allChangedFiles = changedFiles.map((f) => { const node = new ChangedFileNode(f); console.debug(node.toString()); return node; }); - + return allChangedFiles; } + private async getStagedChanges(): Promise { + const stagedFiles = await this.gitService.getStagedFiles(); + return stagedFiles.map((f) => { + const node = new StagedFileNode(f); + console.debug(node.toString()); + return node; + }); + } + private async getTags(): Promise { const tags = await this.gitService.getTags(); - return tags.map((t) => new TreeItemModel({ label: t }, NodeType.Tags, vscode.TreeItemCollapsibleState.None)); + return tags.map( + (t) => + new TreeItemModel( + { label: t }, + NodeType.Tags, + vscode.TreeItemCollapsibleState.None, + ), + ); } 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) => + new TreeItemModel( + { label: s }, + NodeType.Stash, + vscode.TreeItemCollapsibleState.None, + ), + ); } - private getRepositoryChildren(): TreeItemModel[] { - const localBranchesItem = new TreeItemModel( - { label: Constants.LOCAL_BRANCHES_LABEL }, - NodeType.Local, - vscode.TreeItemCollapsibleState.Collapsed, + private async getRepositoryChildren(): Promise { + const stagedFiles = await this.gitService.getStagedFiles(); + const hasStagedFiles = stagedFiles.length > 0; + await vscode.commands.executeCommand( + "setContext", + "gops.hasStagedFiles", + hasStagedFiles, ); - localBranchesItem.iconPath = new vscode.ThemeIcon("go-to-file"); - const remoteBranchesItem = new TreeItemModel( - { label: Constants.REMOTE_BRANCHES_LABEL }, - NodeType.Remote, - vscode.TreeItemCollapsibleState.Collapsed, + const localBranchesItem = new LocalBranchesSection( + this.localBranchesNode?.collapsibleState || + vscode.TreeItemCollapsibleState.Collapsed, ); - remoteBranchesItem.iconPath = new vscode.ThemeIcon("cloud"); + this.localBranchesNode = localBranchesItem; - const changesItem = new TreeItemModel( - { label: Constants.CHANGES_LABEL }, - NodeType.Changes, - vscode.TreeItemCollapsibleState.Collapsed, + const remoteBranchesItem = new RemoteBranchesSection( + this.remoteBranchesNode?.collapsibleState || + vscode.TreeItemCollapsibleState.Collapsed, ); - changesItem.iconPath = new vscode.ThemeIcon("diff"); + this.remoteBranchesNode = remoteBranchesItem; - const tagsItem = new TreeItemModel( - { label: Constants.TAGS_LABEL }, - NodeType.Tags, - vscode.TreeItemCollapsibleState.Collapsed, + const changesItem = new ChangesSection( + this.changesNode?.collapsibleState || + vscode.TreeItemCollapsibleState.Collapsed, ); - tagsItem.iconPath = new vscode.ThemeIcon("tag"); + this.changesNode = changesItem; - const stashItem = new TreeItemModel( - { label: Constants.STASH_LABEL }, - NodeType.Stash, - vscode.TreeItemCollapsibleState.Collapsed, + const stagedItem = new StagedChangesSection( + this.stagedNode?.collapsibleState || + vscode.TreeItemCollapsibleState.Collapsed, ); - stashItem.iconPath = new vscode.ThemeIcon("save"); + this.stagedNode = stagedItem; - return [localBranchesItem, remoteBranchesItem, changesItem, tagsItem, stashItem]; + const tagsItem = new TagsSection( + this.tagsNode?.collapsibleState || + vscode.TreeItemCollapsibleState.Collapsed, + ); + this.tagsNode = tagsItem; + + const stashItem = new StashSection( + this.stashNode?.collapsibleState || + vscode.TreeItemCollapsibleState.Collapsed, + ); + this.stashNode = stashItem; + + return [ + localBranchesItem, + remoteBranchesItem, + changesItem, + stagedItem, + tagsItem, + stashItem, + ]; } refresh(node?: GitTreeNode): void { @@ -148,4 +197,54 @@ export class TreeDataProvider implements vscode.TreeDataProvider { Notifications.info("Git Ops tree view refreshed"); } } + + async refreshRootNode(): Promise { + if (!this.rootNode) { + return; + } + + const currentBranch = await this.gitService.getCurrentBranch(); + this.rootNode.updateActiveBranchLabel(currentBranch); + this._onDidChangeTreeData.fire(this.rootNode); + } + + async refreshLocalBranchesNode(): Promise { + if (!this.localBranchesNode) { + return; + } + this.localBranchesNode.collapsibleState = + vscode.TreeItemCollapsibleState.Expanded; + this._onDidChangeTreeData.fire(this.localBranchesNode); + } + + refreshRemoteBranchesNode(): void { + if (!this.remoteBranchesNode) { + return; + } + this._onDidChangeTreeData.fire(this.remoteBranchesNode); + } + + async refreshChangesNode(): Promise { + if (!this.changesNode) { + return; + } + this._onDidChangeTreeData.fire(this.changesNode); + } + + async refreshStagedNode(): Promise { + if (!this.stagedNode) { + return; + } + const stagedFiles = await this.gitService.getStagedFiles(); + const hasStagedFiles = stagedFiles.length > 0; + await vscode.commands.executeCommand( + "setContext", + "gops.hasStagedFiles", + hasStagedFiles, + ); + this.stagedNode.contextValue = hasStagedFiles + ? ContextValue.StagedChangesSection + : ContextValue.StagedChangesSectionEmpty; + this._onDidChangeTreeData.fire(this.stagedNode); + } } diff --git a/src/gopstree/TreeItemModel.ts b/src/gopstree/TreeItemModel.ts index bd337e5..004a357 100644 --- a/src/gopstree/TreeItemModel.ts +++ b/src/gopstree/TreeItemModel.ts @@ -5,9 +5,9 @@ export class TreeItemModel extends vscode.TreeIte readonly type: T; constructor( - public readonly label: vscode.TreeItemLabel, + public label: vscode.TreeItemLabel, type: T, - public readonly collapsibleState: vscode.TreeItemCollapsibleState, + public collapsibleState: vscode.TreeItemCollapsibleState, public readonly command?: vscode.Command, public readonly children: TreeItemModel[] = [], public parent?: TreeItemModel, diff --git a/src/gopstree/nodes/ChangesSection.ts b/src/gopstree/nodes/ChangesSection.ts new file mode 100644 index 0000000..315a3bf --- /dev/null +++ b/src/gopstree/nodes/ChangesSection.ts @@ -0,0 +1,17 @@ +import { ContextValue } from "../ContextValue"; +import { NodeType } from "./NodeType"; +import { TreeItemModel } from "../TreeItemModel"; +import * as vscode from "vscode"; +import { Constants } from "../../constants/Constants"; + +export class ChangesSection extends TreeItemModel { + constructor(collapsibleState: vscode.TreeItemCollapsibleState) { + super( + { label: Constants.CHANGES_LABEL }, + NodeType.Changes, + collapsibleState, + ); + this.contextValue = ContextValue.ChangesSection; + this.iconPath = new vscode.ThemeIcon("diff"); + } +} diff --git a/src/gopstree/nodes/LocalBranchesSection.ts b/src/gopstree/nodes/LocalBranchesSection.ts new file mode 100644 index 0000000..9662716 --- /dev/null +++ b/src/gopstree/nodes/LocalBranchesSection.ts @@ -0,0 +1,17 @@ +import { ContextValue } from "../ContextValue"; +import { NodeType } from "./NodeType"; +import { TreeItemModel } from "../TreeItemModel"; +import * as vscode from "vscode"; +import { Constants } from "../../constants/Constants"; + +export class LocalBranchesSection extends TreeItemModel { + constructor(collapsibleState: vscode.TreeItemCollapsibleState) { + super( + { label: Constants.LOCAL_BRANCHES_LABEL }, + NodeType.Local, + collapsibleState, + ); + this.contextValue = ContextValue.LocalBranchesSection; + this.iconPath = new vscode.ThemeIcon("go-to-file"); + } +} diff --git a/src/gopstree/nodes/NodeType.ts b/src/gopstree/nodes/NodeType.ts index 071c607..348a2de 100644 --- a/src/gopstree/nodes/NodeType.ts +++ b/src/gopstree/nodes/NodeType.ts @@ -6,6 +6,7 @@ export enum NodeType { Local = "local", Remote = "remote", Changes = "changes", + StagedChanges = "stagedChanges", Tags = "tags", Stash = "stash", diff --git a/src/gopstree/nodes/RemoteBranchesSection.ts b/src/gopstree/nodes/RemoteBranchesSection.ts new file mode 100644 index 0000000..7358f5a --- /dev/null +++ b/src/gopstree/nodes/RemoteBranchesSection.ts @@ -0,0 +1,17 @@ +import { ContextValue } from "../ContextValue"; +import { NodeType } from "./NodeType"; +import { TreeItemModel } from "../TreeItemModel"; +import * as vscode from "vscode"; +import { Constants } from "../../constants/Constants"; + +export class RemoteBranchesSection extends TreeItemModel { + constructor(collapsibleState: vscode.TreeItemCollapsibleState) { + super( + { label: Constants.REMOTE_BRANCHES_LABEL }, + NodeType.Remote, + collapsibleState, + ); + this.contextValue = ContextValue.RemoteBranchesSection; + this.iconPath = new vscode.ThemeIcon("cloud"); + } +} diff --git a/src/gopstree/nodes/RepositoryNode.ts b/src/gopstree/nodes/RepositoryNode.ts index f40a334..18f7499 100644 --- a/src/gopstree/nodes/RepositoryNode.ts +++ b/src/gopstree/nodes/RepositoryNode.ts @@ -1,15 +1,23 @@ import { ContextValue } from "../ContextValue"; import { NodeType } from "./NodeType"; import { TreeItemModel } from "../TreeItemModel"; -import * as vscode from 'vscode'; +import * as vscode from "vscode"; export class RepositoryNode extends TreeItemModel { constructor( public readonly repoName: string, - public readonly branch: string, + branch: string, ) { - super({ label: `${repoName} (${branch})` }, NodeType.Repository, vscode.TreeItemCollapsibleState.Expanded); + super( + { label: `${repoName} (${branch})` }, + NodeType.Repository, + vscode.TreeItemCollapsibleState.Expanded, + ); this.contextValue = ContextValue.Repository; this.iconPath = new vscode.ThemeIcon("repo"); } + + updateActiveBranchLabel(branch: string): void { + this.label = { label: `${this.repoName} (${branch})` }; + } } diff --git a/src/gopstree/nodes/StagedChangesSection.ts b/src/gopstree/nodes/StagedChangesSection.ts new file mode 100644 index 0000000..e6e44ea --- /dev/null +++ b/src/gopstree/nodes/StagedChangesSection.ts @@ -0,0 +1,17 @@ +import { ContextValue } from "../ContextValue"; +import { NodeType } from "./NodeType"; +import { TreeItemModel } from "../TreeItemModel"; +import * as vscode from "vscode"; +import { Constants } from "../../constants/Constants"; + +export class StagedChangesSection extends TreeItemModel { + constructor(collapsibleState: vscode.TreeItemCollapsibleState) { + super( + { label: Constants.STAGED_LABEL }, + NodeType.StagedChanges, + collapsibleState, + ); + this.contextValue = ContextValue.StagedChangesSectionEmpty; + this.iconPath = new vscode.ThemeIcon("diff-added"); + } +} diff --git a/src/gopstree/nodes/StagedFileNode.ts b/src/gopstree/nodes/StagedFileNode.ts new file mode 100644 index 0000000..24cd3d0 --- /dev/null +++ b/src/gopstree/nodes/StagedFileNode.ts @@ -0,0 +1,37 @@ +import { ContextValue } from "../ContextValue"; +import { NodeType } from "./NodeType"; +import { TreeItemModel } from "../TreeItemModel"; +import * as vscode from 'vscode'; +import { createChangedFileTooltip, formatChangedFileLabel } from "./utils/nodeUtils"; +import { COMMANDS } from "../../commands/Commands"; + +export class StagedFileNode extends TreeItemModel { + public override command?: vscode.Command; + constructor( + public readonly fileName: string, + ) { + const fomatted = formatChangedFileLabel(fileName); + super( + { + label: fomatted.label, + highlights: fomatted.highlights, + }, + NodeType.StagedChanges, + vscode.TreeItemCollapsibleState.None, + ); + this.contextValue = ContextValue.StagedChanges; + this.command = { + title: "Show Diff", + command: COMMANDS.SHOW_DIFF, + arguments: [this], + }; + + this.tooltip = createChangedFileTooltip( + fileName, + ); + } + + public toString(): string { + return `ChangedFileNode(${this.fileName}, contextValue=${this.contextValue})`; + } +} \ No newline at end of file diff --git a/src/gopstree/nodes/StashSection.ts b/src/gopstree/nodes/StashSection.ts new file mode 100644 index 0000000..4b33499 --- /dev/null +++ b/src/gopstree/nodes/StashSection.ts @@ -0,0 +1,13 @@ +import { ContextValue } from "../ContextValue"; +import { NodeType } from "./NodeType"; +import { TreeItemModel } from "../TreeItemModel"; +import * as vscode from "vscode"; +import { Constants } from "../../constants/Constants"; + +export class StashSection extends TreeItemModel { + constructor(collapsibleState: vscode.TreeItemCollapsibleState) { + super({ label: Constants.STASH_LABEL }, NodeType.Stash, collapsibleState); + this.contextValue = ContextValue.StashSection; + this.iconPath = new vscode.ThemeIcon("save"); + } +} diff --git a/src/gopstree/nodes/TagsSection.ts b/src/gopstree/nodes/TagsSection.ts new file mode 100644 index 0000000..f49cdf6 --- /dev/null +++ b/src/gopstree/nodes/TagsSection.ts @@ -0,0 +1,13 @@ +import { ContextValue } from "../ContextValue"; +import { NodeType } from "./NodeType"; +import { TreeItemModel } from "../TreeItemModel"; +import * as vscode from "vscode"; +import { Constants } from "../../constants/Constants"; + +export class TagsSection extends TreeItemModel { + constructor(collapsibleState: vscode.TreeItemCollapsibleState) { + super({ label: Constants.TAGS_LABEL }, NodeType.Tags, collapsibleState); + this.contextValue = ContextValue.TagsSection; + this.iconPath = new vscode.ThemeIcon("tag"); + } +} diff --git a/src/services/GitService.ts b/src/services/GitService.ts index 32f4f55..6706704 100644 --- a/src/services/GitService.ts +++ b/src/services/GitService.ts @@ -1,4 +1,11 @@ -import simpleGit, { BranchSummary, DefaultLogFields, ListLogLine, RemoteWithoutRefs, SimpleGit, StatusResult } from "simple-git"; +import simpleGit, { + BranchSummary, + DefaultLogFields, + ListLogLine, + RemoteWithoutRefs, + SimpleGit, + StatusResult, +} from "simple-git"; import * as vscode from "vscode"; import * as path from "path"; import { Logger } from "../logging/Logger"; @@ -28,6 +35,44 @@ export class GitService { return this.git.status(); } + async getChangedFiles(): Promise { + const status = await this.git.status(); + return status.files + .filter((f) => f.working_dir !== " " && f.working_dir !== "?") + .map((f) => f.path); + } + + async getStagedFiles(): Promise { + const status = await this.git.status(); + return status.files + .filter((f) => f.index !== " " && f.index !== "?") + .map((f) => f.path); + } + + async stageFile(filePath: string): Promise { + await this.executeGitAction( + () => this.git.add(filePath), + `Staged file ${filePath} successfully`, + `Failed to stage file ${filePath}`, + ); + } + + async unstageFile(filePath: string): Promise { + await this.executeGitAction( + () => this.git.reset(["HEAD", filePath]), + `Unstaged file ${filePath} successfully`, + `Failed to unstage file ${filePath}`, + ); + } + + async unstageAllFiles(): Promise { + await this.executeGitAction( + () => this.git.reset(["HEAD"]), + "Unstaged all files successfully", + "Failed to unstage all files", + ); + } + async getBranches(): Promise { return this.git.branch(); } @@ -105,8 +150,11 @@ export class GitService { } async commit(message: string) { + const status = await this.git.status(); + Logger.info(`Staged files before commit: ${JSON.stringify(status.staged)}`); + Logger.info(`All files: ${JSON.stringify(status.files)}`); return this.executeGitAction( - () => this.git.commit(message), + () => this.git.commit(message, []), "Commit successful", "Commit failed", ); diff --git a/test/extension.test.ts b/test/extension.test.ts deleted file mode 100644 index f81d1bb..0000000 --- a/test/extension.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import * as assert from "assert"; - -// You can import and use all API from the 'vscode' module -// as well as import your extension to test it -import * as vscode from "vscode"; -// import * as myExtension from '../../extension'; - -declare function suite(name: string, fn: () => void): void; -declare function test(name: string, fn: () => void): void; - -suite("Extension Test Suite", () => { - vscode.window.showInformationMessage("Start all tests."); - - test("Sample test", () => { - assert.strictEqual(-1, [1, 2, 3].indexOf(5)); - assert.strictEqual(-1, [1, 2, 3].indexOf(0)); - }); -}); diff --git a/test/integration/branch.test.ts b/test/integration/branch.test.ts new file mode 100644 index 0000000..4b5d51c --- /dev/null +++ b/test/integration/branch.test.ts @@ -0,0 +1,39 @@ +import * as assert from "node:assert"; +import * as vscode from "vscode"; + +suite("Branch", function () { + this.timeout(30000); + + suiteSetup(async function () { + const extension = vscode.extensions.getExtension("codemanxdev.gops"); + if (!extension?.isActive) { + await extension?.activate(); + } + }); + + test("gops.branch.current should execute without error", async function () { + // Mock input box to simulate cancel + const stub = vscode.window.showInputBox; + (vscode.window as any).showInputBox = async () => undefined; + + await vscode.commands.executeCommand("gops.branch.current"); + + (vscode.window as any).showInputBox = stub; + assert.ok(true, "gops.branch.current completed without error"); + }); + + test("gops.deleteBranch should execute without error", async function () { + await vscode.commands.executeCommand("gops.deleteBranch"); + assert.ok(true, "gops.deleteBranch completed without error"); + }); + + test("gops.renameBranch should execute without error", async function () { + await vscode.commands.executeCommand("gops.renameBranch"); + assert.ok(true, "gops.renameBranch completed without error"); + }); + + test("gops.checkout should execute without error", async function () { + await vscode.commands.executeCommand("gops.checkout"); + assert.ok(true, "gops.checkout completed without error"); + }); +}); diff --git a/test/integration/commands.test.ts b/test/integration/commands.test.ts new file mode 100644 index 0000000..b1805f3 --- /dev/null +++ b/test/integration/commands.test.ts @@ -0,0 +1,69 @@ +import * as assert from "node:assert"; +import * as vscode from "vscode"; + +suite("Commands", function () { + this.timeout(30000); + + suiteSetup(async function () { + const extension = vscode.extensions.getExtension("codemanxdev.gops"); + if (!extension?.isActive) { + await extension?.activate(); + } + }); + + test("should register all commands", async function () { + const commands = await vscode.commands.getCommands(true); + const expectedCommands = [ + "gops.refresh", + "gops.pull", + "gops.push", + "gops.checkout", + "gops.deleteBranch", + "gops.renameBranch", + "gops.branch", + "gops.branch.current", + "gops.tag", + "gops.showDiff", + "gops.stageFile", + "gops.unstageFile", + "gops.unstageAllFiles", + "gops.commit", + ]; + + for (const command of expectedCommands) { + assert.ok( + commands.includes(command), + `Command ${command} must be registered`, + ); + } + }); + + test("gops.refresh should execute without error", async function () { + await vscode.commands.executeCommand("gops.refresh"); + assert.ok(true, "gops.refresh completed without error"); + }); + + test("gops.pull should execute without error", async function () { + try { + await vscode.commands.executeCommand("gops.pull"); + assert.ok(true, "gops.pull completed without error"); + } catch (err: any) { + assert.ok( + err.message.includes("remote") || err.message.includes("origin"), + `gops.pull failed with unexpected error: ${err.message}`, + ); + } + }); + + test("gops.push should execute without error", async function () { + try { + await vscode.commands.executeCommand("gops.push"); + assert.ok(true, "gops.push completed without error"); + } catch (err: any) { + assert.ok( + err.message.includes("remote") || err.message.includes("origin"), + `gops.push failed with unexpected error: ${err.message}`, + ); + } + }); +}); diff --git a/test/integration/commit.test.ts b/test/integration/commit.test.ts new file mode 100644 index 0000000..e9ab734 --- /dev/null +++ b/test/integration/commit.test.ts @@ -0,0 +1,30 @@ +import * as assert from "node:assert"; +import * as vscode from "vscode"; + +suite("Commit", function () { + this.timeout(30000); + + suiteSetup(async function () { + const extension = vscode.extensions.getExtension("codemanxdev.gops"); + if (!extension?.isActive) { + await extension?.activate(); + } + }); + + test("gops.commit should execute without error when no files staged", async function () { + // Mock the input box to return undefined (simulating cancel) + const stub = vscode.window.showInputBox; + (vscode.window as any).showInputBox = async () => undefined; + + await vscode.commands.executeCommand("gops.commit"); + + (vscode.window as any).showInputBox = stub; + assert.ok(true, "gops.commit completed without error"); + }); + + test("gops.refresh should complete after commit attempt", async function () { + await vscode.commands.executeCommand("gops.refresh"); + await new Promise((resolve) => setTimeout(resolve, 500)); + assert.ok(true, "gops.refresh completed after commit attempt"); + }); +}); diff --git a/test/integration/extension.test.ts b/test/integration/extension.test.ts new file mode 100644 index 0000000..93793a7 --- /dev/null +++ b/test/integration/extension.test.ts @@ -0,0 +1,64 @@ +import * as assert from "node:assert"; +import * as vscode from "vscode"; + +suite("Extension", function () { + this.timeout(30000); + + test("should be activated and execute commands", async function () { + assert.ok( + vscode.workspace.workspaceFolders?.length, + "Workspace folder must be available", + ); + + const extension = vscode.extensions.getExtension("codemanxdev.gops"); + assert.ok(extension, "Extension codemanxdev.gops must be found by VS Code"); + + if (!extension.isActive) { + await extension.activate(); + } + + assert.strictEqual(extension.isActive, true, "Extension must be active"); + + await vscode.commands.executeCommand("gops.refresh"); + assert.ok(true, "gops.refresh must complete without error"); + }); + + test("should register the Git Ops tree view", async function () { + const extension = vscode.extensions.getExtension("codemanxdev.gops"); + if (!extension?.isActive) { + await extension?.activate(); + } + + await new Promise((resolve) => setTimeout(resolve, 500)); + + let treeViewAlreadyRegistered = false; + try { + vscode.window.createTreeView("gitOpsTreeview", { + treeDataProvider: + new (class implements vscode.TreeDataProvider { + getTreeItem(_element: unknown): vscode.TreeItem { + return undefined as unknown as vscode.TreeItem; + } + getChildren(): vscode.TreeItem[] { + return []; + } + })(), + }); + assert.fail("createTreeView should have thrown already exists"); + } catch (err: any) { + if ( + typeof err.message === "string" && + err.message.includes("already exists") + ) { + treeViewAlreadyRegistered = true; + } else { + throw err; + } + } + + assert.ok( + treeViewAlreadyRegistered, + "Tree view must be registered by activate()", + ); + }); +}); diff --git a/test/integration/extensionIntegration.test.ts b/test/integration/extensionIntegration.test.ts deleted file mode 100644 index df2b2c1..0000000 --- a/test/integration/extensionIntegration.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import * as assert from "node:assert"; -import * as vscode from "vscode"; - -/** - * Integration tests for the Gops extension. - * - * The workspace folder is opened by `.vscode-test.mjs` via the `workspaceFolder` - * config field. The folder is a minimal git repo created on-the-fly by the config - * script, so GitService / simple-git have a valid repo to work against. - */ -suite("Extension Integration Test Suite", function () { - this.timeout(30000); - - test("Extension should be activated and execute commands", async function () { - // The workspace is opened by the test runner — confirm it is set - assert.ok( - vscode.workspace.workspaceFolders?.length, - "Workspace folder from workspaceFolder config must be available", - ); - - const extension = vscode.extensions.getExtension("codemanxdev.gops"); - assert.ok(extension, "Extension codemanxdev.gops must be found by VS Code"); - - if (!extension.isActive) { - await extension.activate(); - } - - assert.strictEqual( - extension.isActive, - true, - "Extension must be active after activation", - ); - - // gops.refresh is a no-args command registered by CommandRegistrar in activate(). - // A clean resolve confirms the command registry is intact and tree view is registered. - await vscode.commands.executeCommand("gops.refresh"); - assert.ok(true, "gops.refresh must complete without error"); - }); - - test("Extension should register the Git Ops tree view", async function () { - assert.ok( - vscode.workspace.workspaceFolders?.length, - "Workspace folder from workspaceFolder config must be available", - ); - - const extension = vscode.extensions.getExtension("codemanxdev.gops"); - assert.ok(extension, "Extension codemanxdev.gops must be found by VS Code"); - - if (!extension.isActive) { - await extension.activate(); - } - - // Allow tree data provider to populate once - await new Promise((resolve) => setTimeout(resolve, 500)); - - assert.strictEqual( - extension.isActive, - true, - "Extension must be active before checking tree view registration", - ); - - // extension.ts activate() calls: - // vscode.window.createTreeView('gitOpsTreeview', { treeDataProvider }) - // Calling createTreeView again with the same id throws VS Code error E303 - // ('Object for type "view" already exists'). Catching that error proves the - // extension registered the tree view in activate(). - let treeViewAlreadyRegistered = false; - try { - vscode.window.createTreeView("gitOpsTreeview", { - treeDataProvider: - new (class implements vscode.TreeDataProvider { - getTreeItem( - _element: unknown, - ): vscode.TreeItem | Thenable { - return undefined as unknown as vscode.TreeItem; - } - getChildren(): vscode.TreeItem[] | Thenable { - return []; - } - })(), - }); - // If we reach here the view was NOT registered — fail - assert.fail( - 'createTreeView should have thrown "already exists" since activate() registered the view first', - ); - } catch (err: any) { - if ( - typeof err.message === "string" && - err.message.includes("already exists") - ) { - treeViewAlreadyRegistered = true; - } else { - throw err; - } - } - - assert.ok( - treeViewAlreadyRegistered, - 'createTreeView("gitOpsTreeview") must throw "already exists" — ' + - "proving the extension registered the Git Ops tree view in activate()", - ); - }); -}); diff --git a/test/integration/stageFile.test.ts b/test/integration/stageFile.test.ts new file mode 100644 index 0000000..3dd8991 --- /dev/null +++ b/test/integration/stageFile.test.ts @@ -0,0 +1,43 @@ +import * as assert from "node:assert"; +import * as vscode from "vscode"; +import * as path from "node:path"; +import * as fs from "node:fs/promises"; + +suite("Stage/Unstage", function () { + this.timeout(30000); + + const workspacePath = () => vscode.workspace.workspaceFolders![0].uri.fsPath; + + suiteSetup(async function () { + const extension = vscode.extensions.getExtension("codemanxdev.gops"); + if (!extension?.isActive) { + await extension?.activate(); + } + }); + + setup(async function () { + // Create a test file before each test + await fs.writeFile( + path.join(workspacePath(), "stage-test.md"), + "test content", + ); + await new Promise((resolve) => setTimeout(resolve, 200)); + }); + + teardown(async function () { + // Clean up test file after each test + await fs.rm(path.join(workspacePath(), "stage-test.md"), { force: true }); + await new Promise((resolve) => setTimeout(resolve, 200)); + }); + + test("gops.unstageAllFiles should execute without error", async function () { + await vscode.commands.executeCommand("gops.unstageAllFiles"); + assert.ok(true, "gops.unstageAllFiles completed without error"); + }); + + test("gops.refresh should reflect file changes", async function () { + await vscode.commands.executeCommand("gops.refresh"); + await new Promise((resolve) => setTimeout(resolve, 500)); + assert.ok(true, "gops.refresh completed after file change"); + }); +}); diff --git a/test/unit/services/GitService.test.ts b/test/unit/services/GitService.test.ts index ac5abb5..d4ce8d0 100644 --- a/test/unit/services/GitService.test.ts +++ b/test/unit/services/GitService.test.ts @@ -15,7 +15,11 @@ vi.mock("vscode", () => ({ ], }, window: { - createOutputChannel: vi.fn(() => ({ appendLine: vi.fn(), show: vi.fn(), dispose: vi.fn() })), + createOutputChannel: vi.fn(() => ({ + appendLine: vi.fn(), + show: vi.fn(), + dispose: vi.fn(), + })), showInformationMessage: vi.fn(), showWarningMessage: vi.fn(), showErrorMessage: vi.fn(), @@ -43,6 +47,8 @@ const mockGit = { commit: vi.fn(), pull: vi.fn(), checkoutLocalBranch: vi.fn(), + add: vi.fn(), + reset: vi.fn(), }; vi.mock("simple-git", () => ({ @@ -103,8 +109,12 @@ describe("GitService", () => { const result = await service.checkout("feature"); expect(result).toBe("ok"); - expect(infoSpy).toHaveBeenCalledWith("Checked out branch feature successfully"); - expect(notifySpy).toHaveBeenCalledWith("Checked out branch feature successfully"); + expect(infoSpy).toHaveBeenCalledWith( + "Checked out branch feature successfully", + ); + expect(notifySpy).toHaveBeenCalledWith( + "Checked out branch feature successfully", + ); }); it("returns git status from the repository", async () => { @@ -152,7 +162,9 @@ describe("GitService", () => { it("returns file content from a git ref", async () => { mockGit.show.mockResolvedValue("file contents"); - expect(await service.getFileContent("HEAD", "README.md")).toBe("file contents"); + expect(await service.getFileContent("HEAD", "README.md")).toBe( + "file contents", + ); expect(mockGit.show).toHaveBeenCalledWith(["HEAD:README.md"]); }); @@ -175,6 +187,7 @@ describe("GitService", () => { }); it("logs and notifies on successful commit", async () => { + mockGit.status.mockResolvedValue({ staged: [], files: [] }); mockGit.commit.mockResolvedValue("commit-ok"); const infoSpy = vi.spyOn(Logger, "info"); const notifySpy = vi.spyOn(Notifications, "info"); @@ -182,6 +195,7 @@ describe("GitService", () => { const result = await service.commit("message"); expect(result).toBe("commit-ok"); + expect(mockGit.commit).toHaveBeenCalledWith("message", []); expect(infoSpy).toHaveBeenCalledWith("Commit successful"); expect(notifySpy).toHaveBeenCalledWith("Commit successful"); }); @@ -203,11 +217,15 @@ describe("GitService", () => { const infoSpy = vi.spyOn(Logger, "info"); const notifySpy = vi.spyOn(Notifications, "info"); - const result = await service.checkoutBranch("feature","main"); + const result = await service.checkoutBranch("feature", "main"); expect(result).toBe("ok"); - expect(infoSpy).toHaveBeenCalledWith("Checked out branch feature successfully"); - expect(notifySpy).toHaveBeenCalledWith("Checked out branch feature successfully"); + expect(infoSpy).toHaveBeenCalledWith( + "Checked out branch feature successfully", + ); + expect(notifySpy).toHaveBeenCalledWith( + "Checked out branch feature successfully", + ); }); it("logs and notifies on successful checkoutLocalBranch", async () => { @@ -219,7 +237,9 @@ describe("GitService", () => { expect(result).toBe("ok"); expect(infoSpy).toHaveBeenCalledWith("Branch feature created successfully"); - expect(notifySpy).toHaveBeenCalledWith("Branch feature created successfully"); + expect(notifySpy).toHaveBeenCalledWith( + "Branch feature created successfully", + ); }); it("logs error and rethrows when checkout fails", async () => { @@ -236,4 +256,141 @@ describe("GitService", () => { "Checkout failed for branch feature. See details in output", ); }); + + it("logs and notifies on successful stageFile", async () => { + mockGit.add.mockResolvedValue("ok"); + const infoSpy = vi.spyOn(Logger, "info"); + const notifySpy = vi.spyOn(Notifications, "info"); + + await service.stageFile("src/file.ts"); + + expect(mockGit.add).toHaveBeenCalledWith("src/file.ts"); + expect(infoSpy).toHaveBeenCalledWith( + "Staged file src/file.ts successfully", + ); + expect(notifySpy).toHaveBeenCalledWith( + "Staged file src/file.ts successfully", + ); + }); + + it("logs error and rethrows when stageFile fails", async () => { + const error = new Error("stage failed"); + mockGit.add.mockRejectedValue(error); + const errorSpy = vi.spyOn(Logger, "error"); + const notifySpy = vi.spyOn(Notifications, "errorWithOutput"); + + await expect(service.stageFile("src/file.ts")).rejects.toThrow(error); + expect(errorSpy).toHaveBeenCalledWith( + "Failed to stage file src/file.ts: stage failed", + ); + expect(notifySpy).toHaveBeenCalledWith( + "Failed to stage file src/file.ts. See details in output", + ); + }); + + it("logs and notifies on successful unstageFile", async () => { + mockGit.reset.mockResolvedValue("ok"); + const infoSpy = vi.spyOn(Logger, "info"); + const notifySpy = vi.spyOn(Notifications, "info"); + + await service.unstageFile("src/file.ts"); + + expect(mockGit.reset).toHaveBeenCalledWith(["HEAD", "src/file.ts"]); + expect(infoSpy).toHaveBeenCalledWith( + "Unstaged file src/file.ts successfully", + ); + expect(notifySpy).toHaveBeenCalledWith( + "Unstaged file src/file.ts successfully", + ); + }); + + it("logs error and rethrows when unstageFile fails", async () => { + const error = new Error("unstage failed"); + mockGit.reset.mockRejectedValue(error); + const errorSpy = vi.spyOn(Logger, "error"); + const notifySpy = vi.spyOn(Notifications, "errorWithOutput"); + + await expect(service.unstageFile("src/file.ts")).rejects.toThrow(error); + expect(errorSpy).toHaveBeenCalledWith( + "Failed to unstage file src/file.ts: unstage failed", + ); + expect(notifySpy).toHaveBeenCalledWith( + "Failed to unstage file src/file.ts. See details in output", + ); + }); + + it("logs and notifies on successful unstageAllFiles", async () => { + mockGit.reset.mockResolvedValue("ok"); + const infoSpy = vi.spyOn(Logger, "info"); + const notifySpy = vi.spyOn(Notifications, "info"); + + await service.unstageAllFiles(); + + expect(mockGit.reset).toHaveBeenCalledWith(["HEAD"]); + expect(infoSpy).toHaveBeenCalledWith("Unstaged all files successfully"); + expect(notifySpy).toHaveBeenCalledWith("Unstaged all files successfully"); + }); + + it("logs error and rethrows when unstageAllFiles fails", async () => { + const error = new Error("unstage all failed"); + mockGit.reset.mockRejectedValue(error); + const errorSpy = vi.spyOn(Logger, "error"); + const notifySpy = vi.spyOn(Notifications, "errorWithOutput"); + + await expect(service.unstageAllFiles()).rejects.toThrow(error); + expect(errorSpy).toHaveBeenCalledWith( + "Failed to unstage all files: unstage all failed", + ); + expect(notifySpy).toHaveBeenCalledWith( + "Failed to unstage all files. See details in output", + ); + }); + + it("returns only unstaged changed files", async () => { + mockGit.status.mockResolvedValue({ + files: [ + { path: "src/modified.ts", index: " ", working_dir: "M" }, + { path: "src/staged.ts", index: "M", working_dir: " " }, + { path: "src/untracked.ts", index: "?", working_dir: "?" }, + ], + }); + + const result = await service.getChangedFiles(); + + expect(result).toEqual(["src/modified.ts"]); + }); + + it("returns empty array when no unstaged files", async () => { + mockGit.status.mockResolvedValue({ + files: [{ path: "src/staged.ts", index: "M", working_dir: " " }], + }); + + const result = await service.getChangedFiles(); + + expect(result).toEqual([]); + }); + + it("returns only staged files", async () => { + mockGit.status.mockResolvedValue({ + files: [ + { path: "src/modified.ts", index: " ", working_dir: "M" }, + { path: "src/staged.ts", index: "M", working_dir: " " }, + { path: "src/untracked.ts", index: "?", working_dir: "?" }, + ], + }); + + const result = await service.getStagedFiles(); + + expect(result).toEqual(["src/staged.ts"]); + }); + + it("returns empty array when no staged files", async () => { + mockGit.status.mockResolvedValue({ + files: [{ path: "src/modified.ts", index: " ", working_dir: "M" }], + }); + + const result = await service.getStagedFiles(); + + expect(result).toEqual([]); + }); });