Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,24 @@ No changes yet.

---

## [1.0.0] - 2026-03-08

### Added

- **Partial line staging** support. Selected lines can now be staged directly from the editor or diff view using **GW: Stage Selected Lines**.
- Confirmation warning when stashing the **Unversioned** changelist to explain that Git may include additional untracked files in the repository when using `git stash push --include-untracked`.

### Changed

- Progress indicators now appear in the status bar during long-running operations: repository switching, Git status reconciliation, stash creation, and discard all changes.
- Improved handling of stashing the **Unversioned** changelist. Only files currently reported as untracked by Git are considered before creating the stash.
- Renamed `changelistId` to `changelistName` in `GitStashEntry` and related parsing/view code to accurately reflect that the value embedded in the stash message tag (`GW:<encodedName>`) is the changelist name, not an ID.
- File nodes now track a tri-state stage state (`"none"` | `"partial"` | `"all"`) instead of a boolean, enabling correct representation of files with both staged and unstaged changes.
- Fully staged files show a **check icon**
- Partially staged files show a **dash icon**
- Unstaged files show a **square icon**
- Commit preparation now preserves partial staging by avoiding blanket restaging of tracked files. Newly added files modified after staging are refreshed automatically before commit.

## [0.9.0] - 2026-03-06

### Added
Expand Down
30 changes: 26 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

---

**Git Worklists** is a Visual Studio Code extension that provides a lightweight, Git-focused workflow for organizing changes, staging files, committing, pushing, and managing stashes, all through a dedicated, predictable UI.
**Git Worklists** is a Visual Studio Code extension that provides a lightweight, Git-focused workflow for organizing changes, staging files, **partially staging selected lines**, committing, pushing, and managing stashes, all through a dedicated, predictable UI.

It is designed for developers who want **explicit control over staging, commits, amend, push, and stash workflows**, without relying on VS Code’s built-in Source Control view.

Expand Down Expand Up @@ -56,7 +56,6 @@ Git Worklists supports workspaces that contain multiple Git repositories.

---


## Changelists View

A structured way to organize and stage changes.
Expand Down Expand Up @@ -86,6 +85,9 @@ A structured way to organize and stage changes.
- State-aware inline action:
- Shows **Stage** when file is unstaged
- Shows **Unstage** when file is staged
- **Partial staging support**
- Stage only selected lines directly from the editor or diff view
- Files with partially staged changes are visually indicated

- Stage All / Unstage All per changelist

Expand Down Expand Up @@ -152,6 +154,21 @@ A structured way to organize and stage changes.

All staging state reflects the actual Git index.

### Partial Line Staging

![Partial line staging demo](media/demo_partial_stage.gif)


Git Worklists supports staging individual lines directly from the editor or diff view.

1. Open a file diff or source file
2. Select the lines you want to stage
3. Right-click → **GW: Stage Selected Lines**

Only the selected changes are staged. The rest of the file remains unstaged.

Files that contain both staged and unstaged changes are automatically marked as **Partially Staged** in the changelist view.

---

## Commit Panel
Expand Down Expand Up @@ -207,8 +224,12 @@ Integrated Git stash support directly inside Git Worklists.
### Create Stash (Per Changelist)

- Stash all tracked changes from a selected changelist
- Stash the **Unversioned changelist** directly new (untracked) files are included via `--include-untracked`
- Automatically tags stashes with their originating changelist
- Stash the **Unversioned changelist**
Untracked files are stashed using `git stash push --include-untracked`.

**Note:** Git may include additional untracked files in the repository when creating the stash. A confirmation warning is shown before executing this operation.
Automatically tags stashes with their originating changelist

- Optional custom stash message
- Immediate UI refresh after stash

Expand Down Expand Up @@ -251,6 +272,7 @@ Uses **Git CLI directly** (no VS Code SCM provider).
Supported operations:

- `git add`
- `git apply --cached`
- `git restore --staged`
- `git restore --staged --worktree`
- `git commit`
Expand Down
Binary file added media/demo_partial_stage.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified media/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added media/icon_v0.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
27 changes: 21 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
"changelist",
"changelists",
"staging",
"partial staging",
"stage selected lines",
"git staging",
"stash",
"commit",
"push",
Expand Down Expand Up @@ -214,6 +217,11 @@
"command": "gitWorklists.switchRepoRoot",
"title": "Switch Git Root…",
"icon": "$(list-tree)"
},
{
"command": "gitWorklists.stageSelectedLines",
"title": "GW: Stage Selected Lines",
"category": "Git Worklists"
}
],
"menus": {
Expand Down Expand Up @@ -242,7 +250,7 @@
"view/item/context": [
{
"command": "gitWorklists.moveFileToChangelist",
"when": "view == gitWorklists.changelists && (viewItem == gitWorklists.file.staged || viewItem == gitWorklists.file.unstaged)",
"when": "view == gitWorklists.changelists && (viewItem == gitWorklists.file.staged || viewItem == gitWorklists.file.partial || viewItem == gitWorklists.file.unstaged)",
"group": "navigation@0"
},
{
Expand Down Expand Up @@ -277,12 +285,12 @@
},
{
"command": "gitWorklists.unselectFile",
"when": "view == gitWorklists.changelists && viewItem == gitWorklists.file.staged",
"when": "view == gitWorklists.changelists && (viewItem == gitWorklists.file.staged || viewItem == gitWorklists.file.partial)",
"group": "inline@2"
},
{
"command": "gitWorklists.file.discard",
"when": "view == gitWorklists.changelists && (viewItem == gitWorklists.file.staged || viewItem == gitWorklists.file.unstaged)",
"when": "view == gitWorklists.changelists && (viewItem == gitWorklists.file.staged || viewItem == gitWorklists.file.partial || viewItem == gitWorklists.file.unstaged)",
"group": "inline@3"
},
{
Expand Down Expand Up @@ -322,19 +330,26 @@
},
{
"command": "gitWorklists.file.openSource",
"when": "view == gitWorklists.changelists && (viewItem == gitWorklists.file.staged || viewItem == gitWorklists.file.unstaged)",
"when": "view == gitWorklists.changelists && (viewItem == gitWorklists.file.staged || viewItem == gitWorklists.file.partial || viewItem == gitWorklists.file.unstaged)",
"group": "navigation@0"
},
{
"command": "gitWorklists.moveAllStagedFilesToChangelist",
"when": "view == gitWorklists.changelists && viewItem == gitWorklists.file.staged",
"when": "view == gitWorklists.changelists && (viewItem == gitWorklists.file.staged || viewItem == gitWorklists.file.partial)",
"group": "navigation@5"
},
{
"command": "gitWorklists.stashAllStagedFiles",
"when": "view == gitWorklists.changelists && viewItem == gitWorklists.file.staged",
"when": "view == gitWorklists.changelists && (viewItem == gitWorklists.file.staged || viewItem == gitWorklists.file.partial)",
"group": "navigation@6"
}
],
"editor/context": [
{
"command": "gitWorklists.stageSelectedLines",
"when": "editorHasSelection && !editorReadonly",
"group": "gitWorklists@1"
}
]
}
},
Expand Down
97 changes: 82 additions & 15 deletions src/adapters/git/gitCliClient.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import * as cp from "child_process";
import * as fsPromises from "fs/promises";
import * as os from "os";
import * as path from "path";
import { normalizeRepoRelPath } from "../../utils/paths";
import {
CommitFileChange,
FileStageState,
GitClient,
GitStashEntry,
GitStatusEntry,
OutgoingCommit,
StashFileEntry,
} from "./gitClient";
import { normalizeRepoRelPath } from "../../utils/paths";

const GIT_TIMEOUT_MS = 10_000;

Expand Down Expand Up @@ -37,7 +40,9 @@ function execGit(args: string[], cwd: string): Promise<string> {

const timer = setTimeout(() => {
child.kill();
reject(new Error(`git ${args.join(" ")} timed out after ${GIT_TIMEOUT_MS}ms`));
reject(
new Error(`git ${args.join(" ")} timed out after ${GIT_TIMEOUT_MS}ms`),
);
}, GIT_TIMEOUT_MS);

// Clear the timer if the process finishes before timeout
Expand Down Expand Up @@ -69,16 +74,16 @@ export function parseStashLine(line: string): GitStashEntry | null {
const ref = m[1];
const msg = m[2] ?? "";

// Stash messages are tagged as: "GW:<changelistId> <user message>"
// Look for "GW:<id>" token anywhere in the message
// Stash messages are tagged as: "GW:<encodedChangelistName> <user message>"
// Look for "GW:<name>" token anywhere in the message
const gw = msg.match(/\bGW:([^\s]+)/);

return {
ref,
message: msg,
raw: trimmed,
isGitWorklists: !!gw,
changelistId: gw?.[1] ? decodeURIComponent(gw[1]) : undefined,
changelistName: gw?.[1] ? decodeURIComponent(gw[1]) : undefined,
};
}

Expand Down Expand Up @@ -215,6 +220,48 @@ export class GitCliClient implements GitClient {
return staged;
}

async getFileStageStates(
repoRootFsPath: string,
): Promise<Map<string, FileStageState>> {
const out = await execGit(
["status", "--porcelain=v1", "-z"],
repoRootFsPath,
);

const states = new Map<string, FileStageState>();
for (const e of out.split("\0").filter(Boolean)) {
const x = e[0];
const y = e[1];
const p = e.slice(3);
if (!p || x === " " || x === "?") {
continue;
}
const state: FileStageState = y !== " " && y !== "?" ? "partial" : "all";
states.set(normalizeRepoRelPath(p), state);
}
return states;
}

async getDiffUnstaged(
repoRootFsPath: string,
repoRelPath: string,
): Promise<string> {
return execGit(["diff", "--", repoRelPath], repoRootFsPath);
}

async applyPatchStaged(
repoRootFsPath: string,
patch: string,
): Promise<void> {
const tmp = path.join(os.tmpdir(), `gw-${Date.now()}.patch`);
try {
await fsPromises.writeFile(tmp, patch, "utf8");
await execGit(["apply", "--cached", tmp], repoRootFsPath);
} finally {
await fsPromises.unlink(tmp).catch(() => {});
}
}

async getUntrackedPaths(repoRootFsPath: string): Promise<string[]> {
const out = await execGit(
["ls-files", "--others", "--exclude-standard", "-z"],
Expand Down Expand Up @@ -357,7 +404,14 @@ export class GitCliClient implements GitClient {
: ["HEAD", "--not", "--remotes"];

const out = await execGit(
["--no-pager", "-c", "color.ui=false", "log", `--format=${format}`, ...rangeArgs],
[
"--no-pager",
"-c",
"color.ui=false",
"log",
`--format=${format}`,
...rangeArgs,
],
repoRootFsPath,
);

Expand All @@ -372,13 +426,15 @@ export class GitCliClient implements GitClient {
if (!hash || !shortHash) {
return [];
}
return [{
hash,
shortHash,
subject: subject ?? "",
authorName: authorName || undefined,
authorDateIso: authorDateIso || undefined,
}];
return [
{
hash,
shortHash,
subject: subject ?? "",
authorName: authorName || undefined,
authorDateIso: authorDateIso || undefined,
},
];
});
}

Expand All @@ -387,11 +443,22 @@ export class GitCliClient implements GitClient {
commitHash: string,
): Promise<CommitFileChange[]> {
const out = await execGit(
["--no-pager", "-c", "color.ui=false", "show", "--name-status", "--format=", commitHash],
[
"--no-pager",
"-c",
"color.ui=false",
"show",
"--name-status",
"--format=",
commitHash,
],
repoRootFsPath,
);

const lines = out.split("\n").map((l) => l.trim()).filter(Boolean);
const lines = out
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const changes: CommitFileChange[] = [];

for (const line of lines) {
Expand Down
21 changes: 20 additions & 1 deletion src/adapters/git/gitClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export type GitStashEntry = {

/** best-effort parsing */
isGitWorklists?: boolean;
changelistId?: string;
changelistName?: string;
};

export type OutgoingCommit = {
Expand Down Expand Up @@ -58,6 +58,8 @@ export function getStagedFilesInGroup(
return files.map(normalizeRepoRelPath).filter((p) => staged.has(p));
}

export type FileStageState = "none" | "partial" | "all";

export interface GitClient {
/** returns repo root absolute path */
getRepoRoot(workspaceFsPath: string): Promise<string>;
Expand Down Expand Up @@ -88,6 +90,23 @@ export interface GitClient {
*/
getStagedPaths(repoRootFsPath: string): Promise<Set<string>>;

/**
* Returns per-file stage state derived from `git status --porcelain=v1 -z`.
* Only files with at least one staged change appear in the map.
*/
getFileStageStates(
repoRootFsPath: string,
): Promise<Map<string, FileStageState>>;

/** Returns raw output of `git diff -- <repoRelPath>` (unstaged changes: index → worktree). */
getDiffUnstaged(
repoRootFsPath: string,
repoRelPath: string,
): Promise<string>;

/** Applies a unified diff patch to the git index via `git apply --cached`. */
applyPatchStaged(repoRootFsPath: string, patch: string): Promise<void>;

/** Returns repo-relative paths of untracked files (`git ls-files --others`). */
getUntrackedPaths(repoRootFsPath: string): Promise<string[]>;

Expand Down
Loading
Loading