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
234 changes: 230 additions & 4 deletions native/gajae-core/src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ fn dispatch(workdir: &Path, request: &Request, stream: &mut Vec<Value>) -> Resul
"status" => status(workdir, &request.id, &request.params, stream),
"diff" => diff(workdir, &request.id, &request.params, stream),
"worktree.prune" => prune(workdir, &request.params),
"worktree.reap" => reap(workdir, &request.params),
_ => Err(GitError::InvalidRequest),
}
}
Expand Down Expand Up @@ -462,7 +463,83 @@ fn prune(workdir: &Path, params: &Value) -> Result<Value, GitError> {
) {
return Err(GitError::GitFailed);
}
Ok(json!({"pruned":true,"branchRetained":true}))
let reaped = reap_branch(workdir, &branch)?;
Ok(json!({"pruned":true,"branchRetained":!reaped}))
}

/// Deletes a managed job branch once nothing on it can be lost: every commit
/// it points at is reachable from some other local or remote ref. A branch
/// that carries work found nowhere else stays, and the caller learns that it
/// did. `job/*` is this application's own namespace, so the decision needs no
/// user: an empty or already-landed job ref is noise, a ref with unlanded
/// commits is evidence.
fn reap_branch(workdir: &Path, branch: &str) -> Result<bool, GitError> {
let full = format!("refs/heads/{branch}");
if !git_status(workdir, ["show-ref", "--verify", "--quiet", &full]) {
return Ok(false);
}
if !branch_is_contained_elsewhere(workdir, &full)? {
return Ok(false);
}
if !git_status(workdir, ["branch", "-D", "--", branch]) {
return Err(GitError::GitFailed);
}
Ok(true)
}

fn branch_is_contained_elsewhere(workdir: &Path, full_ref: &str) -> Result<bool, GitError> {
let containing = git_text(
workdir,
[
"for-each-ref",
"--format=%(refname)",
"--contains",
full_ref,
"refs/heads",
"refs/remotes",
],
)?;
Ok(containing.lines().any(|line| line != full_ref))
}

/// Reaps every `job/*` branch that no registered worktree uses and whose
/// commits are all reachable elsewhere. This is the recovery for refs that
/// outlived their job record - a reset job store, a hand-removed
/// `.gjc-worktrees/` - and it cannot lose work: a branch with unlanded commits
/// is reported as retained, never deleted.
fn reap(workdir: &Path, params: &Value) -> Result<Value, GitError> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct Reap {}
let _: Reap = serde_json::from_value(params.clone()).map_err(|_| GitError::InvalidRequest)?;
let in_use: Vec<String> = worktrees(workdir)?
.into_iter()
.filter_map(|item| item.branch)
.collect();
let listed = git_text(
workdir,
["for-each-ref", "--format=%(refname)", "refs/heads/job/"],
)?;
let mut reaped = Vec::new();
let mut retained = Vec::new();
for full in listed.lines().filter(|line| !line.is_empty()) {
let Some(branch) = full.strip_prefix("refs/heads/") else {
continue;
};
let Some(job_id) = branch.strip_prefix("job/") else {
continue;
};
if !valid_id(job_id) || in_use.iter().any(|used| used == full) {
retained.push(branch.to_owned());
continue;
}
if reap_branch(workdir, branch)? {
reaped.push(branch.to_owned());
} else {
retained.push(branch.to_owned());
}
}
Ok(json!({"reaped": reaped, "retained": retained}))
}

fn registered(
Expand Down Expand Up @@ -1042,8 +1119,46 @@ mod tests {
assert!(matches!(result, Err(GitError::InvalidRequest)));
}

fn branch_exists(repo: &TestRepo, branch: &str) -> bool {
git_status(
&repo.path,
[
"show-ref",
"--verify",
"--quiet",
&format!("refs/heads/{branch}"),
],
)
}

fn commit_in(dir: &Path, file: &str, message: &str) {
std::fs::write(dir.join(file), format!("{message}\n")).unwrap();
for args in [
vec!["add", file],
vec![
"-c",
"user.name=Gajae Test",
"-c",
"user.email=gajae@example.test",
"commit",
"--quiet",
"-m",
message,
],
] {
assert!(
Command::new("git")
.args(args)
.current_dir(dir)
.status()
.unwrap()
.success()
);
}
}

#[test]
fn prune_preserves_ignored_files_and_retains_clean_worktree_branches() {
fn prune_preserves_ignored_files_and_reaps_a_branch_with_nothing_of_its_own() {
let repo = TestRepo::new();
let path = repo.path.join(".gjc-worktrees/job-1");
let params = json!({"jobId":"job-1", "branch":"job/job-1", "path":path, "confirmed":true});
Expand All @@ -1057,12 +1172,123 @@ mod tests {
);
assert!(matches!(result, Err(GitError::DirtyWorktree)));
std::fs::remove_file(path.join(".env")).unwrap();
assert_eq!(prune(&repo.path, &params).unwrap()["pruned"], true);
let pruned = prune(&repo.path, &params).unwrap();
assert_eq!(pruned["pruned"], true);
assert!(!path.exists());
// The branch still pointed at the base commit, which main holds: a ref
// to nothing of its own is noise, and it goes with the worktree.
assert_eq!(pruned["branchRetained"], false);
assert!(!branch_exists(&repo, "job/job-1"));
}

#[test]
fn prune_retains_a_branch_whose_commits_exist_nowhere_else() {
let repo = TestRepo::new();
let path = repo.path.join(".gjc-worktrees/job-1");
let params = json!({"jobId":"job-1", "branch":"job/job-1", "path":path, "confirmed":true});
create(&repo.path, &params).unwrap();
commit_in(&path, "work.txt", "unlanded work");
let pruned = prune(&repo.path, &params).unwrap();
assert_eq!(pruned["pruned"], true);
assert!(!path.exists());
assert_eq!(
pruned["branchRetained"], true,
"unlanded commits are evidence, not noise"
);
assert!(branch_exists(&repo, "job/job-1"));
}

#[test]
fn reap_deletes_orphaned_job_refs_whose_commits_landed_and_keeps_the_rest() {
let repo = TestRepo::new();
// job-empty: created and its worktree removed by hand; the ref points
// at the base commit main already holds.
let empty = repo.path.join(".gjc-worktrees/job-empty");
create(
&repo.path,
&json!({"jobId":"job-empty", "branch":"job/job-empty", "path":empty}),
)
.unwrap();
// job-landed: committed on the branch, merged into main, worktree gone.
let landed = repo.path.join(".gjc-worktrees/job-landed");
create(
&repo.path,
&json!({"jobId":"job-landed", "branch":"job/job-landed", "path":landed}),
)
.unwrap();
commit_in(&landed, "landed.txt", "landed work");
// job-unlanded: committed on the branch and nowhere else.
let unlanded = repo.path.join(".gjc-worktrees/job-unlanded");
create(
&repo.path,
&json!({"jobId":"job-unlanded", "branch":"job/job-unlanded", "path":unlanded}),
)
.unwrap();
commit_in(&unlanded, "orphan.txt", "unlanded work");
// job-live: its worktree is still registered, whatever its commits.
let live = repo.path.join(".gjc-worktrees/job-live");
create(
&repo.path,
&json!({"jobId":"job-live", "branch":"job/job-live", "path":live}),
)
.unwrap();
for path in [&empty, &landed, &unlanded] {
assert!(git_status(
&repo.path,
[
"worktree",
"remove",
"--force",
"--",
path.to_str().unwrap()
]
));
}
assert!(git_status(
&repo.path,
["show-ref", "--verify", "--quiet", "refs/heads/job/job-1"]
["merge", "--quiet", "--no-edit", "job/job-landed"]
));

let result = reap(&repo.path, &json!({})).unwrap();
let names = |key: &str| -> Vec<String> {
result[key]
.as_array()
.unwrap()
.iter()
.map(|value| value.as_str().unwrap().to_owned())
.collect()
};
let mut reaped = names("reaped");
reaped.sort();
assert_eq!(reaped, ["job/job-empty", "job/job-landed"]);
let mut retained = names("retained");
retained.sort();
assert_eq!(retained, ["job/job-live", "job/job-unlanded"]);
assert!(!branch_exists(&repo, "job/job-empty"));
assert!(!branch_exists(&repo, "job/job-landed"));
assert!(branch_exists(&repo, "job/job-unlanded"));
assert!(branch_exists(&repo, "job/job-live"));
assert!(live.exists(), "a registered worktree is never touched");

// A second pass finds nothing new to do and changes nothing.
let again = reap(&repo.path, &json!({})).unwrap();
assert!(again["reaped"].as_array().unwrap().is_empty());
assert!(matches!(
reap(&repo.path, &json!({"jobId":"job-live"})),
Err(GitError::InvalidRequest)
));
}

#[test]
fn reap_leaves_branches_outside_the_job_namespace_alone() {
let repo = TestRepo::new();
assert!(git_status(&repo.path, ["branch", "feature/empty"]));
assert!(git_status(&repo.path, ["branch", "jobs"]));
let result = reap(&repo.path, &json!({})).unwrap();
assert!(result["reaped"].as_array().unwrap().is_empty());
assert!(result["retained"].as_array().unwrap().is_empty());
assert!(branch_exists(&repo, "feature/empty"));
assert!(branch_exists(&repo, "jobs"));
}

#[test]
Expand Down
16 changes: 16 additions & 0 deletions server/GJC-LIVE-SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,22 @@ facade. Automatic capacity dispatch, multi-turn continuity, and branch/PR work
from managed worktrees are deferred to Slice 3. Worker Protocol v1 and all React
behavior are unchanged.

A managed worktree's `job/<id>` ref lives exactly as long as something needs
it (#157). `worktree.prune` removes the checkout and then deletes the branch
unless it holds a commit reachable from no other local or remote ref, in which
case the reply says `branchRetained: true`; unlanded work is evidence, not
noise. Deleting a worktree session permanently (`DELETE
/sessions/:id?force=true`) archives its job, prunes its checkout and reaps its
ref this way; it refuses with `SESSION_WORKTREE_RUNNING` while the job runs
and `SESSION_WORKTREE_DIRTY` while the checkout has uncommitted changes, and
leaves the session in place in both cases. Archiving a session keeps checkout
and ref. Refs that outlived their record - a reset job store, a
`.gjc-worktrees/` removed by hand - are swept by `worktree.reap`, which the
orchestrator runs once per repository per process before its first worktree
there: it deletes every `job/*` ref with no registered worktree and no commit
of its own, reports the rest as retained, and never touches a branch outside
the namespace.

### Native PTY lifecycle

`gajae-core pty -- <program> [args...]` owns exactly one native PTY child and
Expand Down
4 changes: 2 additions & 2 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ import {
getProductionJobOrchestrator,
getProductionNativeJobsDesktopRestartReader,
} from './services/gjc-job-orchestrator.js';
import { readSessionLocation, resolveSessionWorkspacePath, validateSessionRepository } from './services/session-worktree-paths.js';
import { readSessionLocation, releaseSessionWorktree, resolveSessionWorkspacePath, validateSessionRepository } from './services/session-worktree-paths.js';
import {
abortSessionWorktreeRun,
configureSessionWorktreeDesktopAdmission,
Expand Down Expand Up @@ -151,7 +151,7 @@ function getPendingProviderApprovalsForSession(sessionId) {
const gjcJobAuthority = getProductionJobAuthority();
const desktopNativeInit = new DesktopNativeInit();
automationService.configureNativeInitialization(desktopNativeInit);
configureSessionWorktrees({ validateRepository: validateSessionRepository, readLocation: readSessionLocation, resolveWorkspace: resolveSessionWorkspacePath });
configureSessionWorktrees({ validateRepository: validateSessionRepository, readLocation: readSessionLocation, resolveWorkspace: resolveSessionWorkspacePath, release: releaseSessionWorktree });
const gjcJobOrchestrator = getProductionJobOrchestrator();
const gjcJobProjection = new GjcJobProjectionService({
get: (params) => gjcJobAuthority.get(params),
Expand Down
1 change: 1 addition & 0 deletions server/modules/database/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
import { scanStateDb as scanState } from '@/modules/database/repositories/scan-state.db.js';
import { sessionsDb as sessions } from '@/modules/database/repositories/sessions.db.js';
import { sessionWorktreesDb as sessionWorktrees } from '@/modules/database/repositories/session-worktrees.db.js';
export type { SessionWorktreeRow } from '@/modules/database/repositories/session-worktrees.db.js';
import { userDb as users } from '@/modules/database/repositories/users.js';

export {
Expand Down
19 changes: 17 additions & 2 deletions server/modules/providers/services/session-worktrees.service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { randomUUID } from 'node:crypto';

import { getConnection, sessionsDb, sessionWorktreesDb } from '@/modules/database/index.js';
import { getConnection, sessionsDb, sessionWorktreesDb, type SessionWorktreeRow } from '@/modules/database/index.js';
import { resolveSessionProjectPath } from '@/modules/providers/services/session-project-path.service.js';
import { AppError } from '@/shared/utils.js';

import type { SessionLocation } from '../../../../shared/session-worktree-protocol.js';

type Services = { validateRepository(root: string): Promise<void>; readLocation(sessionId: string): SessionLocation; resolveWorkspace(projectId: string, sessionId: string): Promise<string> };
type Services = {
validateRepository(root: string): Promise<void>;
readLocation(sessionId: string): SessionLocation;
resolveWorkspace(projectId: string, sessionId: string): Promise<string>;
release(row: SessionWorktreeRow): Promise<{ released: boolean; branchRetained: boolean }>;
};
let services: Services | undefined;
export function configureSessionWorktrees(value: Services): void { services = value; }
function requireServices(): Services {
Expand All @@ -21,6 +26,16 @@ export function sessionTranscriptWorkspace(sessionId: string, projectPath: strin
if (binding.repository_root !== projectPath || !binding.worktree_path) throw new AppError('Session execution directory is unavailable.', { code: 'SESSION_WORKTREE_UNAVAILABLE', statusCode: 409 });
return binding.worktree_path;
}
/**
* Tears down a worktree session's checkout before its row goes. A session in
* the project checkout has nothing to release. Refusals (running, dirty) are
* AppErrors the route surfaces; the session row is untouched in that case.
*/
export async function releaseWorktreeSession(sessionId: string): Promise<{ released: boolean; branchRetained: boolean } | null> {
const binding = sessionWorktreesDb.get(sessionId);
if (!binding) return null;
return requireServices().release(binding);
}
export function resolveSessionCommandWorkspace(projectId: string | undefined, sessionId: string): Promise<string> {
if (!projectId) throw new AppError('projectId is required for a session workspace.', { code: 'PROJECT_ID_REQUIRED', statusCode: 400 });
return requireServices().resolveWorkspace(projectId, sessionId);
Expand Down
5 changes: 4 additions & 1 deletion server/modules/providers/services/sessions.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import path from 'node:path';
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
import { providerRegistry } from '@/modules/providers/provider.registry.js';
import { resolveSessionProjectPath } from '@/modules/providers/services/session-project-path.service.js';
import { sessionTranscriptWorkspace } from '@/modules/providers/services/session-worktrees.service.js';
import { releaseWorktreeSession, sessionTranscriptWorkspace } from '@/modules/providers/services/session-worktrees.service.js';
import { chatRunRegistry } from '@/modules/websocket/index.js';
import type { FetchHistoryOptions, FetchHistoryResult, LLMProvider, NormalizedMessage } from '@/shared/types.js';
import { boundToolResultDetails, prepareMessagesForTransport } from '@/shared/tool-output-transport.js';
Expand Down Expand Up @@ -187,6 +187,9 @@ export const sessionsService = {
sessionsDb.updateSessionIsArchived(sessionId, true);
return { sessionId, action: 'archived', deletedFromDisk: false };
}
// A worktree session's checkout and job ref go with the session; a refusal
// (running, uncommitted changes) leaves the session in place and says why.
await releaseWorktreeSession(sessionId);
const deletedFromDisk = options.deletedFromDisk && row.jsonl_path ? await unlinkWhenPresent(row.jsonl_path) : false;
if (!sessionsDb.deleteSessionById(sessionId)) throw sessionNotFound(sessionId);
return { sessionId, action: 'deleted', deletedFromDisk };
Expand Down
2 changes: 2 additions & 0 deletions server/services/gjc-git-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,4 +403,6 @@ export class GjcGitClient extends GjcNativeClient {
status(params: Record<string, unknown> = {}): Promise<unknown> { return this.request('status', params); }
diff(params: Record<string, unknown> = {}): Promise<unknown> { return this.request('diff', params); }
prune(params: Record<string, unknown> = {}): Promise<unknown> { return this.request('worktree.prune', params); }
/** Deletes orphaned `job/*` refs whose commits all exist elsewhere; reports what it kept. */
reap(): Promise<unknown> { return this.request('worktree.reap', {}); }
}
Loading