diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d4c75f..ed7fa47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,20 @@ No changes yet. --- +## [0.8.2] - 2026-03-04 + +### Fixed + +- `stageChangelistAll` / `unstageChangelistAll` commands now correctly use the active repo root instead of always reading from `workspaceFolders[0]` +- Stash restore round-trip now works correctly for changelist names containing spaces — names are URL-encoded in the stash message and decoded on apply/pop + +### Changed + +- Refactored all components to read `repoRoot` at call-time via getter functions instead of capturing it once at startup — groundwork for multi-repo switching +- `StashesTreeProvider` now exposes `setRepoRoot()` to allow switching repos without recreating the provider + +--- + ## [0.8.1] - 2026-03-02 - Version number bumped unintentionally; no functional changes vs 0.7.0. diff --git a/src/adapters/git/gitCliClient.ts b/src/adapters/git/gitCliClient.ts index e3bdd71..446a4cc 100644 --- a/src/adapters/git/gitCliClient.ts +++ b/src/adapters/git/gitCliClient.ts @@ -78,7 +78,7 @@ export function parseStashLine(line: string): GitStashEntry | null { message: msg, raw: trimmed, isGitWorklists: !!gw, - changelistId: gw?.[1], + changelistId: gw?.[1] ? decodeURIComponent(gw[1]) : undefined, }; } diff --git a/src/adapters/vscode/autoRefreshController.ts b/src/adapters/vscode/autoRefreshController.ts index 03efe46..c96ca0f 100644 --- a/src/adapters/vscode/autoRefreshController.ts +++ b/src/adapters/vscode/autoRefreshController.ts @@ -6,8 +6,8 @@ export class AutoRefreshController implements DisposableLike { constructor( private readonly vs: VscodeFacade, - private readonly repoRoot: string, - private readonly gitDir: string, + private readonly getRepoRoot: () => string, + private readonly getGitDir: () => string, private readonly onSignal: () => void, ) {} @@ -17,7 +17,7 @@ export class AutoRefreshController implements DisposableLike { const isInRepo = (uri: UriLike) => { const fsPath = uri.fsPath; - const rel = path.relative(this.repoRoot, fsPath); + const rel = path.relative(this.getRepoRoot(), fsPath); return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel); }; @@ -46,7 +46,7 @@ export class AutoRefreshController implements DisposableLike { } private watchGitFile(relativePath: string) { - const pattern = new this.vs.RelativePattern(this.gitDir, relativePath); + const pattern = new this.vs.RelativePattern(this.getGitDir(), relativePath); const w = this.vs.workspace.createFileSystemWatcher(pattern); this.disposables.push( diff --git a/src/adapters/vscode/gitShowContentProvider.ts b/src/adapters/vscode/gitShowContentProvider.ts index 78498c5..ac9b67e 100644 --- a/src/adapters/vscode/gitShowContentProvider.ts +++ b/src/adapters/vscode/gitShowContentProvider.ts @@ -11,7 +11,7 @@ export class GitShowContentProvider constructor( private readonly git: GitClient, - private readonly repoRoot: string, + private readonly getRepoRoot: () => string, ) {} async provideTextDocumentContent(uri: vscode.Uri): Promise { @@ -19,14 +19,12 @@ export class GitShowContentProvider const ref = decodeURIComponent(parts[0] ?? "HEAD"); const repoRel = decodeURIComponent(parts.slice(1).join("/")); - // Special ref for “no left content” (added files / first commit) if (ref === "EMPTY") { return ""; } - // Use optional show to avoid crashing when file doesn not exist at ref const txt = await this.git.showFileAtRefOptional( - this.repoRoot, + this.getRepoRoot(), ref, repoRel, ); diff --git a/src/app/deps.ts b/src/app/deps.ts index 531e4d0..64b54fc 100644 --- a/src/app/deps.ts +++ b/src/app/deps.ts @@ -70,8 +70,12 @@ export async function createDeps( const treeProvider = new ChangelistTreeProvider(store); treeProvider.setRepoRoot(repoRoot); + // Forward ref: deps is assigned below. All closures capture `deps` and read + // deps.repoRoot at call-time so a future switchRepo() mutation is picked up. + let deps!: Deps; + let onDndDrop: () => Promise = async () => {}; - const dnd = new ChangelistDragDrop(moveFiles, () => repoRoot, () => onDndDrop()); + const dnd = new ChangelistDragDrop(moveFiles, () => deps.repoRoot, () => onDndDrop()); const treeView = vscode.window.createTreeView("gitWorklists.changelists", { treeDataProvider: treeProvider, @@ -88,12 +92,12 @@ export async function createDeps( const reconcile = new ReconcileWithGitStatus(git, store); const coordinator = new RefreshCoordinator(async () => { - await loadOrInit.run(repoRoot); - await reconcile.run(repoRoot); + await loadOrInit.run(deps.repoRoot); + await reconcile.run(deps.repoRoot); treeProvider.refresh(); deco.refreshAll(); - const state = await store.load(repoRoot); + const state = await store.load(deps.repoRoot); const totalFiles = state?.version === 1 ? state.lists.reduce((sum, l) => sum + l.files.length, 0) @@ -120,7 +124,7 @@ export async function createDeps( const pendingStageOnSave = new PendingStageOnSave(); const newFileHandler = new HandleNewFilesCreated({ - repoRoot, + getRepoRoot: () => deps.repoRoot, git, moveFiles, coordinator, @@ -130,7 +134,7 @@ export async function createDeps( }); // commitView set in registerCommitView.ts - const deps: Deps = { + deps = { context, workspaceFolder, repoRoot, diff --git a/src/registration/registerAutoRefresh.ts b/src/registration/registerAutoRefresh.ts index 2f88554..220429d 100644 --- a/src/registration/registerAutoRefresh.ts +++ b/src/registration/registerAutoRefresh.ts @@ -8,8 +8,8 @@ export function registerAutoRefresh( ) { const auto = new AutoRefreshController( { workspace: vscode.workspace, RelativePattern: vscode.RelativePattern }, - deps.repoRoot, - deps.gitDir, + () => deps.repoRoot, + () => deps.gitDir, doRefresh, ); diff --git a/src/registration/registerCommands.ts b/src/registration/registerCommands.ts index 434be7b..3c61572 100644 --- a/src/registration/registerCommands.ts +++ b/src/registration/registerCommands.ts @@ -527,14 +527,6 @@ export function registerCommands(deps: Deps) { return; } - const repoRoot = await deps.git.tryGetRepoRoot( - vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? "", - ); - if (!repoRoot) { - vscode.window.showErrorMessage("No Git repository found."); - return; - } - const ok = await vscode.window.showWarningMessage( `Stage all files in "${group.list.name}"?`, { modal: true }, @@ -544,7 +536,7 @@ export function registerCommands(deps: Deps) { return; } - await stageChangelistAll(deps.git, repoRoot, group.list.files); + await stageChangelistAll(deps.git, deps.repoRoot, group.list.files); await vscode.commands.executeCommand("gitWorklists.refresh"); }, ), @@ -556,14 +548,6 @@ export function registerCommands(deps: Deps) { return; } - const repoRoot = await deps.git.tryGetRepoRoot( - vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? "", - ); - if (!repoRoot) { - vscode.window.showErrorMessage("No Git repository found."); - return; - } - const ok = await vscode.window.showWarningMessage( `Unstage all files in "${group.list.name}"? (Working tree changes will be kept.)`, { modal: true }, @@ -573,7 +557,7 @@ export function registerCommands(deps: Deps) { return; } - await unstageChangelistAll(deps.git, repoRoot, group.list.files); + await unstageChangelistAll(deps.git, deps.repoRoot, group.list.files); await vscode.commands.executeCommand("gitWorklists.refresh"); }, ), diff --git a/src/registration/registerViews.ts b/src/registration/registerViews.ts index 2240534..cd4e84e 100644 --- a/src/registration/registerViews.ts +++ b/src/registration/registerViews.ts @@ -10,7 +10,7 @@ export function registerViews(context: vscode.ExtensionContext, deps: Deps) { vscode.window.registerFileDecorationProvider(deps.deco), ); - const showProvider = new GitShowContentProvider(deps.git, deps.repoRoot); + const showProvider = new GitShowContentProvider(deps.git, () => deps.repoRoot); context.subscriptions.push( vscode.workspace.registerTextDocumentContentProvider( GitShowContentProvider.scheme, diff --git a/src/test/unit/adapters/git/gitCliClient.test.ts b/src/test/unit/adapters/git/gitCliClient.test.ts index 0ba8d53..10f37cd 100644 --- a/src/test/unit/adapters/git/gitCliClient.test.ts +++ b/src/test/unit/adapters/git/gitCliClient.test.ts @@ -1,5 +1,4 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import * as path from "path"; const mocks = vi.hoisted(() => { return { @@ -109,6 +108,24 @@ describe("parseStashLine", () => { changelistId: undefined, }); }); + + it("decodes URL-encoded changelist name with spaces", () => { + const e = parseStashLine("stash@{0}: On main: GW:My%20Feature WIP"); + expect(e).toMatchObject({ + ref: "stash@{0}", + isGitWorklists: true, + changelistId: "My Feature", + }); + }); + + it("decodes URL-encoded Unversioned Files name", () => { + const e = parseStashLine("stash@{1}: On main: GW:Unversioned%20Files"); + expect(e).toMatchObject({ + ref: "stash@{1}", + isGitWorklists: true, + changelistId: "Unversioned Files", + }); + }); }); describe("GitCliClient (mocked git)", () => { @@ -183,7 +200,7 @@ describe("GitCliClient (mocked git)", () => { const git = new GitCliClient(); const gitDir = await git.getGitDir("/repo"); - expect(gitDir).toBe(path.join("/repo", ".git")); + expect(gitDir).toBe("/repo/.git"); }); it("getGitDir returns absolute path unchanged", async () => { diff --git a/src/test/unit/adapters/vscode/autoRefreshController.test.ts b/src/test/unit/adapters/vscode/autoRefreshController.test.ts index f44fdae..eebdb6e 100644 --- a/src/test/unit/adapters/vscode/autoRefreshController.test.ts +++ b/src/test/unit/adapters/vscode/autoRefreshController.test.ts @@ -103,7 +103,7 @@ describe("AutoRefreshController", () => { const { vs, createdPatterns, watchers } = makeVscodeStub(); const onSignal = vi.fn(); - const c = new AutoRefreshController(vs, "/repo", "/repo/.git", onSignal); + const c = new AutoRefreshController(vs, () => "/repo", () => "/repo/.git", onSignal); c.start(); expect(watchers.length).toBe(2); @@ -117,7 +117,7 @@ describe("AutoRefreshController", () => { const { vs, watchers } = makeVscodeStub(); const onSignal = vi.fn(); - const c = new AutoRefreshController(vs, "/repo", "/repo/.git", onSignal); + const c = new AutoRefreshController(vs, () => "/repo", () => "/repo/.git", onSignal); c.start(); // fire watcher events @@ -132,7 +132,7 @@ describe("AutoRefreshController", () => { const { vs, fire } = makeVscodeStub(); const onSignal = vi.fn(); - const c = new AutoRefreshController(vs, "/repo", "/repo/.git", onSignal); + const c = new AutoRefreshController(vs, () => "/repo", () => "/repo/.git", onSignal); c.start(); fire.createFiles({ @@ -161,7 +161,7 @@ describe("AutoRefreshController", () => { const { vs, watchers } = makeVscodeStub(); const onSignal = vi.fn(); - const c = new AutoRefreshController(vs, "/repo", "/repo/.git", onSignal); + const c = new AutoRefreshController(vs, () => "/repo", () => "/repo/.git", onSignal); c.start(); c.dispose(); diff --git a/src/test/unit/adapters/vscode/gitShowContentProvider.test.ts b/src/test/unit/adapters/vscode/gitShowContentProvider.test.ts index d13868b..80657ff 100644 --- a/src/test/unit/adapters/vscode/gitShowContentProvider.test.ts +++ b/src/test/unit/adapters/vscode/gitShowContentProvider.test.ts @@ -47,7 +47,7 @@ describe("GitShowContentProvider", () => { showFileAtRefOptional: vi.fn().mockResolvedValue("content"), }; - const provider = new GitShowContentProvider(git as any, "/repo"); + const provider = new GitShowContentProvider(git as any, () => "/repo"); const uri = { path: "/HEAD/src/a.ts" } as any; @@ -66,7 +66,7 @@ describe("GitShowContentProvider", () => { showFileAtRefOptional: vi.fn().mockResolvedValue("x"), }; - const provider = new GitShowContentProvider(git as any, "/repo"); + const provider = new GitShowContentProvider(git as any, () => "/repo"); const uri = { path: "/stash%40%7B2%7D/a%20b.txt" } as any; @@ -84,7 +84,7 @@ describe("GitShowContentProvider", () => { showFileAtRefOptional: vi.fn().mockResolvedValue("x"), }; - const provider = new GitShowContentProvider(git as any, "/repo"); + const provider = new GitShowContentProvider(git as any, () => "/repo"); const uri = { path: "" } as any; @@ -98,7 +98,7 @@ describe("GitShowContentProvider", () => { showFileAtRefOptional: vi.fn(), }; - const provider = new GitShowContentProvider(git as any, "/repo"); + const provider = new GitShowContentProvider(git as any, () => "/repo"); const uri = { path: "/EMPTY/src/a.ts" } as any; @@ -110,7 +110,7 @@ describe("GitShowContentProvider", () => { it("refresh fires onDidChange event with the same uri", () => { const git: GitClientMock = { showFileAtRefOptional: vi.fn() }; - const provider = new GitShowContentProvider(git as any, "/repo"); + const provider = new GitShowContentProvider(git as any, () => "/repo"); const uri = { path: "/HEAD/src/a.ts" } as any; @@ -122,7 +122,7 @@ describe("GitShowContentProvider", () => { it("exposes onDidChange event (smoke test)", () => { const git: GitClientMock = { showFileAtRefOptional: vi.fn() }; - const provider = new GitShowContentProvider(git as any, "/repo"); + const provider = new GitShowContentProvider(git as any, () => "/repo"); expect(provider.onDidChange).toBeDefined(); }); diff --git a/src/test/unit/usecases/stash/createStashForChangelist.test.ts b/src/test/unit/usecases/stash/createStashForChangelist.test.ts index 4ff5291..87c7c0d 100644 --- a/src/test/unit/usecases/stash/createStashForChangelist.test.ts +++ b/src/test/unit/usecases/stash/createStashForChangelist.test.ts @@ -235,7 +235,7 @@ describe("CreateStashForChangelist", () => { expect(git.getStatusPorcelainZ).not.toHaveBeenCalled(); expect(git.stashPushPaths).toHaveBeenCalledWith( "/repo", - "GW:Unversioned Files", + "GW:Unversioned%20Files", ["new-a.ts", "new-b.ts"], { includeUntracked: true }, ); diff --git a/src/usecases/handleNewFilesCreated.ts b/src/usecases/handleNewFilesCreated.ts index fd2e96f..85c6938 100644 --- a/src/usecases/handleNewFilesCreated.ts +++ b/src/usecases/handleNewFilesCreated.ts @@ -22,7 +22,7 @@ export type MoveFilesPort = { }; export type HandleNewFilesCreatedDeps = { - repoRoot: string; + getRepoRoot: () => string; git: GitPort; moveFiles: MoveFilesPort; coordinator: RefreshPort; @@ -46,7 +46,8 @@ export class HandleNewFilesCreated { constructor(private readonly deps: HandleNewFilesCreatedDeps) {} async run(createdFileUris: vscode.Uri[]): Promise { - const { repoRoot, settings, prompt, coordinator } = this.deps; + const repoRoot = this.deps.getRepoRoot(); + const { settings, prompt, coordinator } = this.deps; if (!settings.getPromptOnNewFile()) { return; @@ -124,7 +125,7 @@ export class HandleNewFilesCreated { private async moveToDefault(paths: string[]): Promise { await this.deps.moveFiles.run( - this.deps.repoRoot, + this.deps.getRepoRoot(), paths, SystemChangelist.Default, ); @@ -132,7 +133,7 @@ export class HandleNewFilesCreated { private async moveToUnversioned(paths: string[]): Promise { await this.deps.moveFiles.run( - this.deps.repoRoot, + this.deps.getRepoRoot(), paths, SystemChangelist.Unversioned, ); diff --git a/src/usecases/stash/createStashForChangelist.ts b/src/usecases/stash/createStashForChangelist.ts index 0db35e7..c82e66f 100644 --- a/src/usecases/stash/createStashForChangelist.ts +++ b/src/usecases/stash/createStashForChangelist.ts @@ -36,9 +36,10 @@ export class CreateStashForChangelist { } const userMsg = (params.message ?? "").trim(); + const encoded = encodeURIComponent(changelistName); const msg = userMsg - ? `GW:${changelistName} ${userMsg}` - : `GW:${changelistName}`; + ? `GW:${encoded} ${userMsg}` + : `GW:${encoded}`; if (changelistId === SystemChangelist.Unversioned) { await this.git.stashPushPaths(repoRootFsPath, msg, files, { diff --git a/src/views/stash/stashNodes.ts b/src/views/stash/stashNodes.ts index 8836cc9..7d0d445 100644 --- a/src/views/stash/stashNodes.ts +++ b/src/views/stash/stashNodes.ts @@ -15,7 +15,7 @@ function stripWorklistTag(msg: string): { changelistId?: string; msg: string } { const s = (msg ?? "").trim(); const m = s.match(/\b(?:GW|CL):([^\s]+)\b/); - const changelistId = m?.[1]; + const changelistId = m?.[1] ? decodeURIComponent(m[1]) : undefined; const cleaned = s.replace(/\b(?:GW|CL):[^\s]+\b\s*/g, "").trim(); diff --git a/src/views/stash/stashesTreeProvider.ts b/src/views/stash/stashesTreeProvider.ts index 21c3e80..58e024d 100644 --- a/src/views/stash/stashesTreeProvider.ts +++ b/src/views/stash/stashesTreeProvider.ts @@ -14,10 +14,14 @@ export class StashesTreeProvider private disposed = false; constructor( - private readonly repoRootFsPath: string, + private repoRootFsPath: string, private readonly git: GitClient, ) {} + setRepoRoot(repoRootFsPath: string): void { + this.repoRootFsPath = repoRootFsPath; + } + refresh(): void { this.onDidChangeTreeDataEmitter.fire(undefined); }