Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
00d879b
Stream git status updates over WebSocket
juliusmarminge Apr 5, 2026
0cd98e3
Stabilize git status state and browser bootstrap
juliusmarminge Apr 5, 2026
e8503a9
Share git status subscriptions across consumers
juliusmarminge Apr 5, 2026
366182e
Use per-thread git status subscriptions
juliusmarminge Apr 5, 2026
8b9304c
Keep SidebarThreadRow hooks ordered before early return
juliusmarminge Apr 5, 2026
60b9b6c
Remove git status refresh on menu open
juliusmarminge Apr 5, 2026
c7460b8
Add explicit git status refresh RPC
juliusmarminge Apr 5, 2026
507822b
Split local and remote git status streaming
juliusmarminge Apr 5, 2026
0a3812a
Avoid phantom null git status atoms
juliusmarminge Apr 5, 2026
f3bc116
Fix git status browser test mocks
juliusmarminge Apr 5, 2026
564e94d
Background git status refresh after successful mutations
juliusmarminge Apr 5, 2026
6840b70
Stop idle git remote pollers on disconnect
juliusmarminge Apr 5, 2026
fc50e65
Fix remote-only git status fallback state
juliusmarminge Apr 5, 2026
5cf6f7f
Run git status refresh in server background scope
juliusmarminge Apr 6, 2026
f5bec5e
Use Scope.close for WS background scope
juliusmarminge Apr 6, 2026
5326507
Debounce git status refreshes on window focus
juliusmarminge Apr 6, 2026
a499106
Detach git status refresh after git actions
juliusmarminge Apr 6, 2026
acb9bf1
Scope branch query invalidation to current cwd
juliusmarminge Apr 6, 2026
5efdbb0
Restore branch selector state after checkout failures
juliusmarminge Apr 6, 2026
2c200ce
Preserve local git status fields on remote refresh
juliusmarminge Apr 6, 2026
e234ed0
Fix git status polling keys and pending snapshots
juliusmarminge Apr 6, 2026
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
3 changes: 2 additions & 1 deletion apps/server/src/git/Layers/GitCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -949,11 +949,12 @@ it.layer(TestLayer)("git integration", (it) => {
yield* git(source, ["checkout", defaultBranch]);
yield* git(source, ["branch", "-D", featureBranch]);

yield* (yield* GitCore).checkoutBranch({
const checkoutResult = yield* (yield* GitCore).checkoutBranch({
cwd: source,
branch: `${remoteName}/${featureBranch}`,
});

expect(checkoutResult.branch).toBe("upstream/feature");
expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature");
const realGitCore = yield* GitCore;
let fetchArgs: readonly string[] | null = null;
Expand Down
23 changes: 20 additions & 3 deletions apps/server/src/git/Layers/GitCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1177,9 +1177,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
return branchLastCommit;
});

const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) {
yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true }));

const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* (cwd: string) {
const statusResult = yield* executeGit(
"GitCore.statusDetails.status",
cwd,
Expand Down Expand Up @@ -1312,6 +1310,17 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
};
});

const statusDetailsLocal: GitCoreShape["statusDetailsLocal"] = Effect.fn("statusDetailsLocal")(
function* (cwd) {
return yield* readStatusDetailsLocal(cwd);
},
);

const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) {
yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true }));
return yield* readStatusDetailsLocal(cwd);
});

const status: GitCoreShape["status"] = (input) =>
statusDetails(input.cwd).pipe(
Effect.map((details) => ({
Expand Down Expand Up @@ -2078,6 +2087,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
timeoutMs: 10_000,
fallbackErrorMessage: "git checkout failed",
});

const branch = yield* runGitStdout("GitCore.checkoutBranch.currentBranch", input.cwd, [
"branch",
"--show-current",
]).pipe(Effect.map((stdout) => stdout.trim() || null));

return { branch };
},
);

Expand Down Expand Up @@ -2106,6 +2122,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
execute,
status,
statusDetails,
statusDetailsLocal,
prepareCommitContext,
commit,
pushCurrentBranch,
Expand Down
136 changes: 104 additions & 32 deletions apps/server/src/git/Layers/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ import {
GitCommandError,
GitRunStackedActionResult,
GitStackedAction,
type GitStatusLocalResult,
type GitStatusRemoteResult,
ModelSelection,
} from "@t3tools/contracts";
import {
detectGitHostingProviderFromRemoteUrl,
mergeGitStatusParts,
resolveAutoFeatureBranchName,
sanitizeBranchFragment,
sanitizeFeatureBranchName,
Expand Down Expand Up @@ -695,26 +699,55 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {

const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp";
const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd);
const readStatus = Effect.fn("readStatus")(function* (cwd: string) {
const details = yield* gitCore.statusDetails(cwd).pipe(
Effect.catchIf(isNotGitRepositoryError, () =>
Effect.succeed({
isRepo: false,
hasOriginRemote: false,
isDefaultBranch: false,
branch: null,
upstreamRef: null,
hasWorkingTreeChanges: false,
workingTree: { files: [], insertions: 0, deletions: 0 },
hasUpstream: false,
aheadCount: 0,
behindCount: 0,
} satisfies GitStatusDetails),
),
);
const nonRepositoryStatusDetails = {
isRepo: false,
hasOriginRemote: false,
isDefaultBranch: false,
branch: null,
upstreamRef: null,
hasWorkingTreeChanges: false,
workingTree: { files: [], insertions: 0, deletions: 0 },
hasUpstream: false,
aheadCount: 0,
behindCount: 0,
} satisfies GitStatusDetails;
const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) {
const details = yield* gitCore
.statusDetailsLocal(cwd)
.pipe(
Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)),
);
const hostingProvider = details.isRepo
? yield* resolveHostingProvider(cwd, details.branch)
: null;

return {
isRepo: details.isRepo,
...(hostingProvider ? { hostingProvider } : {}),
hasOriginRemote: details.hasOriginRemote,
isDefaultBranch: details.isDefaultBranch,
branch: details.branch,
hasWorkingTreeChanges: details.hasWorkingTreeChanges,
workingTree: details.workingTree,
} satisfies GitStatusLocalResult;
});
const localStatusResultCache = yield* Cache.makeWith({
capacity: STATUS_RESULT_CACHE_CAPACITY,
lookup: readLocalStatus,
timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero),
});
const invalidateLocalStatusResultCache = (cwd: string) =>
Cache.invalidate(localStatusResultCache, normalizeStatusCacheKey(cwd));
const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (cwd: string) {
const details = yield* gitCore
.statusDetails(cwd)
.pipe(Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(null)));
if (details === null || !details.isRepo) {
return null;
}

const pr =
details.isRepo && details.branch !== null
details.branch !== null
? yield* findLatestPr(cwd, {
branch: details.branch,
upstreamRef: details.upstreamRef,
Expand All @@ -725,29 +758,38 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
: null;

return {
isRepo: details.isRepo,
hasOriginRemote: details.hasOriginRemote,
isDefaultBranch: details.isDefaultBranch,
branch: details.branch,
hasWorkingTreeChanges: details.hasWorkingTreeChanges,
workingTree: details.workingTree,
hasUpstream: details.hasUpstream,
aheadCount: details.aheadCount,
behindCount: details.behindCount,
pr,
};
} satisfies GitStatusRemoteResult;
});
const statusResultCache = yield* Cache.makeWith({
const remoteStatusResultCache = yield* Cache.makeWith({
capacity: STATUS_RESULT_CACHE_CAPACITY,
lookup: readStatus,
lookup: readRemoteStatus,
timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero),
});
const invalidateStatusResultCache = (cwd: string) =>
Cache.invalidate(statusResultCache, normalizeStatusCacheKey(cwd));
const invalidateRemoteStatusResultCache = (cwd: string) =>
Cache.invalidate(remoteStatusResultCache, normalizeStatusCacheKey(cwd));

const readConfigValueNullable = (cwd: string, key: string) =>
gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null)));

const resolveHostingProvider = Effect.fn("resolveHostingProvider")(function* (
cwd: string,
branch: string | null,
) {
const preferredRemoteName =
branch === null
? "origin"
: ((yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)) ?? "origin");
const remoteUrl =
(yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ??
(yield* readConfigValueNullable(cwd, "remote.origin.url"));

return remoteUrl ? detectGitHostingProviderFromRemoteUrl(remoteUrl) : null;
});

const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* (
cwd: string,
remoteName: string | null,
Expand Down Expand Up @@ -1311,9 +1353,34 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
};
});

const localStatus: GitManagerShape["localStatus"] = Effect.fn("localStatus")(function* (input) {
return yield* Cache.get(localStatusResultCache, normalizeStatusCacheKey(input.cwd));
});
const remoteStatus: GitManagerShape["remoteStatus"] = Effect.fn("remoteStatus")(
function* (input) {
return yield* Cache.get(remoteStatusResultCache, normalizeStatusCacheKey(input.cwd));
},
);
const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) {
return yield* Cache.get(statusResultCache, normalizeStatusCacheKey(input.cwd));
const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)]);
return mergeGitStatusParts(local, remote);
});
const invalidateLocalStatus: GitManagerShape["invalidateLocalStatus"] = Effect.fn(
"invalidateLocalStatus",
)(function* (cwd) {
yield* invalidateLocalStatusResultCache(cwd);
});
const invalidateRemoteStatus: GitManagerShape["invalidateRemoteStatus"] = Effect.fn(
"invalidateRemoteStatus",
)(function* (cwd) {
yield* invalidateRemoteStatusResultCache(cwd);
});
const invalidateStatus: GitManagerShape["invalidateStatus"] = Effect.fn("invalidateStatus")(
function* (cwd) {
yield* invalidateLocalStatusResultCache(cwd);
yield* invalidateRemoteStatusResultCache(cwd);
},
);

const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")(
function* (input) {
Expand Down Expand Up @@ -1488,7 +1555,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
branch: worktree.worktree.branch,
worktreePath: worktree.worktree.path,
};
}).pipe(Effect.ensuring(invalidateStatusResultCache(input.cwd)));
}).pipe(Effect.ensuring(invalidateStatus(input.cwd)));
});

const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* (
Expand Down Expand Up @@ -1692,7 +1759,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
});

return yield* runAction().pipe(
Effect.ensuring(invalidateStatusResultCache(input.cwd)),
Effect.ensuring(invalidateStatus(input.cwd)),
Effect.tapError((error) =>
Effect.flatMap(Ref.get(currentPhase), (phase) =>
progress.emit({
Expand All @@ -1707,7 +1774,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
);

return {
localStatus,
remoteStatus,
status,
invalidateLocalStatus,
invalidateRemoteStatus,
invalidateStatus,
resolvePullRequest,
preparePullRequestThread,
runStackedAction,
Expand Down
Loading
Loading