soja-cli 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +18 -0
- package/README.md +117 -0
- package/dist/application/chat.js +1 -0
- package/dist/application/filters.js +27 -0
- package/dist/application/import.js +1 -0
- package/dist/application/ports.js +1 -0
- package/dist/application/repository-root.js +18 -0
- package/dist/application/services/backup-service.js +93 -0
- package/dist/application/services/folder-service.js +116 -0
- package/dist/application/services/git-workflow-service.js +593 -0
- package/dist/application/services/index.js +24 -0
- package/dist/application/services/project-service.js +129 -0
- package/dist/application/services/session-service.js +52 -0
- package/dist/application/services/task-service.js +350 -0
- package/dist/application/services/update-service.js +60 -0
- package/dist/application/services/workspace-service.js +81 -0
- package/dist/application/timeline.js +83 -0
- package/dist/application/types.js +1 -0
- package/dist/application/validation.js +30 -0
- package/dist/bootstrap.js +91 -0
- package/dist/cli/commands/access.js +60 -0
- package/dist/cli/commands/account.js +203 -0
- package/dist/cli/commands/args.js +27 -0
- package/dist/cli/commands/backup.js +70 -0
- package/dist/cli/commands/chat.js +133 -0
- package/dist/cli/commands/dev.js +59 -0
- package/dist/cli/commands/folders.js +59 -0
- package/dist/cli/commands/git-ops.js +200 -0
- package/dist/cli/commands/import.js +48 -0
- package/dist/cli/commands/info.js +82 -0
- package/dist/cli/commands/project.js +108 -0
- package/dist/cli/commands/start.js +34 -0
- package/dist/cli/commands/sync.js +43 -0
- package/dist/cli/commands/task.js +309 -0
- package/dist/cli/commands/tui.js +43 -0
- package/dist/cli/commands/update.js +20 -0
- package/dist/cli/commands/workspace.js +55 -0
- package/dist/cli/index.js +103 -0
- package/dist/cli/output.js +56 -0
- package/dist/cli/prompt.js +28 -0
- package/dist/cli/runtime.js +43 -0
- package/dist/config/config.js +103 -0
- package/dist/config/credentials.js +68 -0
- package/dist/config/paths.js +18 -0
- package/dist/config/server.js +21 -0
- package/dist/config/version.js +7 -0
- package/dist/data/local/activity.js +33 -0
- package/dist/data/local/clock.js +1 -0
- package/dist/data/local/comments.js +27 -0
- package/dist/data/local/index.js +48 -0
- package/dist/data/local/projects.js +53 -0
- package/dist/data/local/tasks.js +104 -0
- package/dist/data/local/users.js +38 -0
- package/dist/data/local/workspaces.js +55 -0
- package/dist/data/remote/api-client.js +103 -0
- package/dist/data/remote/live.js +93 -0
- package/dist/data/repositories.js +1 -0
- package/dist/data/sync/chat.js +242 -0
- package/dist/data/sync/engine.js +459 -0
- package/dist/data/sync/importer.js +156 -0
- package/dist/data/sync/index.js +109 -0
- package/dist/data/sync/services.js +263 -0
- package/dist/data/sync/store.js +200 -0
- package/dist/database/client.js +76 -0
- package/dist/database/migrate.js +27 -0
- package/dist/database/migrations/0000_init.sql +100 -0
- package/dist/database/migrations/0001_git-operations.sql +19 -0
- package/dist/database/migrations/0002_merge-detection.sql +19 -0
- package/dist/database/migrations/0003_sync-replica.sql +40 -0
- package/dist/database/migrations/0004_chat-replica.sql +52 -0
- package/dist/database/migrations/0005_pr-events.sql +18 -0
- package/dist/database/migrations/0006_archive.sql +20 -0
- package/dist/database/migrations/meta/0000_snapshot.json +710 -0
- package/dist/database/migrations/meta/0001_snapshot.json +717 -0
- package/dist/database/migrations/meta/0002_snapshot.json +724 -0
- package/dist/database/migrations/meta/0003_snapshot.json +972 -0
- package/dist/database/migrations/meta/0004_snapshot.json +1168 -0
- package/dist/database/migrations/meta/0005_snapshot.json +1168 -0
- package/dist/database/migrations/meta/0006_snapshot.json +1183 -0
- package/dist/database/migrations/meta/_journal.json +55 -0
- package/dist/database/schema.js +170 -0
- package/dist/dev/seed.js +84 -0
- package/dist/domain/activity.js +22 -0
- package/dist/domain/chat.js +21 -0
- package/dist/domain/entities.js +1 -0
- package/dist/domain/errors.js +23 -0
- package/dist/domain/naming.js +33 -0
- package/dist/domain/task.js +140 -0
- package/dist/domain/workflow.js +23 -0
- package/dist/git/cli-git.js +363 -0
- package/dist/git/console.js +37 -0
- package/dist/git/diagnose.js +145 -0
- package/dist/git/merge-evidence.js +23 -0
- package/dist/git/releases.js +95 -0
- package/dist/git/types.js +16 -0
- package/dist/ui/App.js +71 -0
- package/dist/ui/Shell.js +211 -0
- package/dist/ui/app-state.js +213 -0
- package/dist/ui/branding/CompactLogo.js +8 -0
- package/dist/ui/branding/Logo.js +22 -0
- package/dist/ui/branding/Splash.js +21 -0
- package/dist/ui/branding/brand.js +23 -0
- package/dist/ui/chrome/Sidebar.js +50 -0
- package/dist/ui/chrome/StatusBar.js +26 -0
- package/dist/ui/chrome/Toast.js +11 -0
- package/dist/ui/chrome/TopBar.js +10 -0
- package/dist/ui/chrome/context.js +33 -0
- package/dist/ui/components/ConsoleLines.js +16 -0
- package/dist/ui/components/EmptyState.js +8 -0
- package/dist/ui/components/Labels.js +25 -0
- package/dist/ui/components/PullRequestLabel.js +27 -0
- package/dist/ui/components/TaskTable.js +41 -0
- package/dist/ui/components/TextField.js +11 -0
- package/dist/ui/components/TextInput.js +29 -0
- package/dist/ui/components/task-columns.js +27 -0
- package/dist/ui/copy.js +23 -0
- package/dist/ui/hooks/use-commands.js +58 -0
- package/dist/ui/hooks/use-flows.js +254 -0
- package/dist/ui/hooks/use-layout.js +36 -0
- package/dist/ui/hooks/use-list.js +28 -0
- package/dist/ui/hooks/use-measure.js +22 -0
- package/dist/ui/hooks/use-query.js +29 -0
- package/dist/ui/hooks/use-task-actions.js +450 -0
- package/dist/ui/hooks/use-terminal-size.js +19 -0
- package/dist/ui/input/KeyProvider.js +101 -0
- package/dist/ui/input/dispatcher.js +26 -0
- package/dist/ui/input/list-navigation.js +38 -0
- package/dist/ui/input/mouse.js +78 -0
- package/dist/ui/input/text-editing.js +62 -0
- package/dist/ui/kit/Badge.js +8 -0
- package/dist/ui/kit/Button.js +13 -0
- package/dist/ui/kit/Clickable.js +14 -0
- package/dist/ui/kit/Gauge.js +9 -0
- package/dist/ui/kit/Keycap.js +7 -0
- package/dist/ui/kit/Panel.js +30 -0
- package/dist/ui/kit/ScrollBar.js +14 -0
- package/dist/ui/kit/Spinner.js +9 -0
- package/dist/ui/kit/Tabs.js +12 -0
- package/dist/ui/mascot/AgentScene.js +30 -0
- package/dist/ui/mascot/Mascot.js +26 -0
- package/dist/ui/mascot/ansi.js +25 -0
- package/dist/ui/mascot/engine.js +146 -0
- package/dist/ui/mascot/index.js +6 -0
- package/dist/ui/mascot/palette.js +18 -0
- package/dist/ui/mascot/scenes/bugHunt.js +115 -0
- package/dist/ui/mascot/scenes/celebrate.js +76 -0
- package/dist/ui/mascot/scenes/cleaning.js +156 -0
- package/dist/ui/mascot/scenes/clockIn.js +105 -0
- package/dist/ui/mascot/scenes/deadline.js +125 -0
- package/dist/ui/mascot/scenes/empty.js +149 -0
- package/dist/ui/mascot/scenes/fire.js +138 -0
- package/dist/ui/mascot/scenes/index.js +25 -0
- package/dist/ui/mascot/scenes/planning.js +73 -0
- package/dist/ui/mascot/scenes/resting.js +90 -0
- package/dist/ui/mascot/scenes/syncing.js +116 -0
- package/dist/ui/mascot/scenes/working.js +193 -0
- package/dist/ui/mascot/selection.js +32 -0
- package/dist/ui/mascot/states.js +25 -0
- package/dist/ui/navigation/routes.js +17 -0
- package/dist/ui/overlays/CommitOverlay.js +93 -0
- package/dist/ui/overlays/ConfirmOverlay.js +42 -0
- package/dist/ui/overlays/GitLogOverlay.js +22 -0
- package/dist/ui/overlays/GitRunOverlay.js +94 -0
- package/dist/ui/overlays/NewTaskOverlay.js +117 -0
- package/dist/ui/overlays/OverlayFrame.js +35 -0
- package/dist/ui/overlays/PickerOverlay.js +72 -0
- package/dist/ui/overlays/PromptOverlay.js +51 -0
- package/dist/ui/overlays/SearchOverlay.js +49 -0
- package/dist/ui/overlays/options.js +29 -0
- package/dist/ui/overlays/types.js +1 -0
- package/dist/ui/screens/AccessScreen.js +91 -0
- package/dist/ui/screens/ChatScreen.js +450 -0
- package/dist/ui/screens/HelpScreen.js +99 -0
- package/dist/ui/screens/ProjectsScreen.js +66 -0
- package/dist/ui/screens/ScreenFrame.js +14 -0
- package/dist/ui/screens/SetupScreen.js +79 -0
- package/dist/ui/screens/TaskListScreen.js +152 -0
- package/dist/ui/screens/TaskScreen.js +193 -0
- package/dist/ui/screens/WelcomeScreen.js +254 -0
- package/dist/ui/screens/WorkspacesScreen.js +64 -0
- package/dist/ui/theme/detect.js +59 -0
- package/dist/ui/theme/theme.js +135 -0
- package/dist/utils/editor.js +37 -0
- package/dist/utils/errors.js +23 -0
- package/dist/utils/text.js +60 -0
- package/dist/utils/time.js +25 -0
- package/docs/NPM_PUBLISH.md +35 -0
- package/docs/PRIVACY.md +38 -0
- package/package.json +74 -0
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { SojaError } from '../../domain/errors.js';
|
|
3
|
+
import { formatTaskRef, isClosed, suggestBranchName, taskMentionPattern } from '../../domain/task.js';
|
|
4
|
+
import { findMergeEvidence } from '../../git/merge-evidence.js';
|
|
5
|
+
import { ValidationError } from '../../domain/errors.js';
|
|
6
|
+
import { GitError } from '../../git/types.js';
|
|
7
|
+
/** Reasons to think twice before merging a pull request, in plain words. */
|
|
8
|
+
export function pullRequestWarnings(pr) {
|
|
9
|
+
return [
|
|
10
|
+
pr.checks.failed > 0 ? `${pr.checks.failed} check${pr.checks.failed === 1 ? '' : 's'} failing` : null,
|
|
11
|
+
pr.checks.pending > 0 ? `${pr.checks.pending} still running` : null,
|
|
12
|
+
pr.review === 'changes_requested' ? 'changes requested' : pr.review === 'review_required' ? 'not approved yet' : null,
|
|
13
|
+
pr.mergeable === 'conflicting' ? 'conflicts with the base' : null,
|
|
14
|
+
].filter((warning) => warning !== null);
|
|
15
|
+
}
|
|
16
|
+
const COMMIT_LIMIT = 10;
|
|
17
|
+
/** Pull request lists are reused for this long, so moving around the lists does not call GitHub each time. */
|
|
18
|
+
const PR_CACHE_MS = 60_000;
|
|
19
|
+
const PR_LIST_LIMIT = 100;
|
|
20
|
+
/**
|
|
21
|
+
* Local Git flow for tasks. Everything here works offline. Git runs first
|
|
22
|
+
* and SOJA records the result afterwards, so a Git failure never leaves a
|
|
23
|
+
* task "started" on a branch that does not exist.
|
|
24
|
+
*/
|
|
25
|
+
export class GitWorkflowService {
|
|
26
|
+
tasks;
|
|
27
|
+
projects;
|
|
28
|
+
git;
|
|
29
|
+
now;
|
|
30
|
+
pullRequestCache = new Map();
|
|
31
|
+
constructor(tasks, projects, git, now = Date.now) {
|
|
32
|
+
this.tasks = tasks;
|
|
33
|
+
this.projects = projects;
|
|
34
|
+
this.git = git;
|
|
35
|
+
this.now = now;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Decides the repository and branch for `target`. The repository is the
|
|
39
|
+
* task's project repository; a task without a project uses the repository
|
|
40
|
+
* containing `cwd`. Throws a GitError explaining what to fix otherwise.
|
|
41
|
+
*/
|
|
42
|
+
async plan(session, target, cwd) {
|
|
43
|
+
const task = await this.tasks.get(session, target);
|
|
44
|
+
const { root, linkProject } = await this.repositoryFor(session, task, cwd);
|
|
45
|
+
const branch = task.branch ?? suggestBranchName(task);
|
|
46
|
+
if (!(await this.git.isValidBranchName(root, branch))) {
|
|
47
|
+
throw new GitError(`“${branch}” is not a valid Git branch name.`, {
|
|
48
|
+
hint: `Fix the branch recorded on ${task.ref} from its edit menu.`,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const [currentBranch, branchExists, uncommitted] = await Promise.all([
|
|
52
|
+
this.git.currentBranch(root),
|
|
53
|
+
this.git.branchExists(root, branch),
|
|
54
|
+
this.git.uncommittedChanges(root),
|
|
55
|
+
]);
|
|
56
|
+
return {
|
|
57
|
+
task,
|
|
58
|
+
root,
|
|
59
|
+
branch,
|
|
60
|
+
branchExists,
|
|
61
|
+
checkedOut: currentBranch === branch,
|
|
62
|
+
currentBranch,
|
|
63
|
+
uncommitted,
|
|
64
|
+
linkProject,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* `soja start`: checks out the task branch (creating it from `from`, or
|
|
69
|
+
* from the current HEAD) and then assigns the task to you, moves it to
|
|
70
|
+
* In Progress and records the branch.
|
|
71
|
+
*/
|
|
72
|
+
async start(session, target, options) {
|
|
73
|
+
const plan = await this.plan(session, target, options.cwd);
|
|
74
|
+
const ref = formatTaskRef(plan.task.number);
|
|
75
|
+
if (plan.linkProject && !options.link) {
|
|
76
|
+
throw new GitError(`${plan.linkProject.name} has no repository linked.`, {
|
|
77
|
+
hint: `Use --link to link ${plan.root}, or run \`soja project link ${plan.linkProject.key} <path>\`.`,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
let action;
|
|
81
|
+
let base = null;
|
|
82
|
+
let carried = 0;
|
|
83
|
+
if (plan.checkedOut) {
|
|
84
|
+
action = 'current';
|
|
85
|
+
}
|
|
86
|
+
else if (plan.branchExists) {
|
|
87
|
+
if (plan.uncommitted > 0) {
|
|
88
|
+
throw new GitError(`${plan.root} has ${plural(plan.uncommitted, 'uncommitted change')}.`, {
|
|
89
|
+
hint: `Commit or stash them before switching to ${plan.branch}.`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
await this.git.switchBranch(plan.root, plan.branch);
|
|
93
|
+
action = 'switched';
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
base = options.from ?? plan.currentBranch;
|
|
97
|
+
await this.git.createBranch(plan.root, plan.branch, options.from);
|
|
98
|
+
action = plan.task.branch ? 'recreated' : 'created';
|
|
99
|
+
carried = plan.uncommitted;
|
|
100
|
+
}
|
|
101
|
+
// Git succeeded; from here on only SOJA's own data changes.
|
|
102
|
+
let linked = false;
|
|
103
|
+
if (plan.linkProject) {
|
|
104
|
+
await this.projects.linkRepository(session, plan.linkProject, plan.root);
|
|
105
|
+
linked = true;
|
|
106
|
+
}
|
|
107
|
+
let task;
|
|
108
|
+
try {
|
|
109
|
+
// The base is only known when SOJA creates the branch; keep an earlier one otherwise.
|
|
110
|
+
const baseBranch = base ?? plan.task.baseBranch;
|
|
111
|
+
// Where the branch starts; only commits after it count as the task's own work.
|
|
112
|
+
const branchStart = action === 'created' || action === 'recreated' ? await this.git.resolveCommit(plan.root, 'HEAD') : plan.task.branchStart;
|
|
113
|
+
task = await this.tasks.start(session, plan.task, { branch: plan.branch, baseBranch, branchStart });
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
throw new SojaError(`Switched to ${plan.branch}, but ${ref} could not be updated.`, {
|
|
117
|
+
hint: `Run \`soja start ${ref}\` again; the branch is already there.`,
|
|
118
|
+
cause: error,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return { task, root: plan.root, branch: plan.branch, action, base, carried, linked };
|
|
122
|
+
}
|
|
123
|
+
/** Changed files and branch context, for choosing what to commit. */
|
|
124
|
+
async workingState(session, target, cwd) {
|
|
125
|
+
const context = await this.context(session, target, cwd);
|
|
126
|
+
const [files, merging] = await Promise.all([this.git.changedFiles(context.root), this.git.isMerging(context.root)]);
|
|
127
|
+
return { ...context, files, merging };
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Commits exactly `paths` on the task branch. The task reference is added
|
|
131
|
+
* to the message (`Fix webhook (SOJA-12)`) unless it is already there.
|
|
132
|
+
*/
|
|
133
|
+
async commit(session, target, options) {
|
|
134
|
+
const context = await this.context(session, target, options.cwd);
|
|
135
|
+
this.requireOnTaskBranch(context);
|
|
136
|
+
const text = options.message.trim();
|
|
137
|
+
if (!text)
|
|
138
|
+
throw new ValidationError('Write a commit message.');
|
|
139
|
+
if (options.paths.length === 0)
|
|
140
|
+
throw new ValidationError('Select at least one file to commit.');
|
|
141
|
+
const available = await this.git.changedFiles(context.root);
|
|
142
|
+
const chosen = available.filter((file) => options.paths.includes(file.path));
|
|
143
|
+
if (chosen.length === 0)
|
|
144
|
+
throw new GitError('None of those files have changes anymore.', { code: 'nothing_to_commit' });
|
|
145
|
+
const ref = formatTaskRef(context.task.number);
|
|
146
|
+
const mentions = new RegExp(taskMentionPattern(context.task.number), 'i').test(text);
|
|
147
|
+
const message = mentions ? text : `${text} (${ref})`;
|
|
148
|
+
const hash = await this.git.commit(context.root, message, chosen);
|
|
149
|
+
const subject = message.split('\n')[0] ?? message;
|
|
150
|
+
await this.record(session, context.task, { type: 'git_committed', metadata: { hash, subject, files: chosen.length } });
|
|
151
|
+
return { hash, subject, files: chosen.length };
|
|
152
|
+
}
|
|
153
|
+
/** Where `merge` would go and whether it is needed. */
|
|
154
|
+
async mergePlan(session, target, cwd) {
|
|
155
|
+
const context = await this.context(session, target, cwd);
|
|
156
|
+
const branch = this.requireBranch(context);
|
|
157
|
+
if (!(await this.git.branchExists(context.root, branch))) {
|
|
158
|
+
throw new GitError(`${branch} does not exist in this repository.`, { suggestions: ['Press b to create it again.'] });
|
|
159
|
+
}
|
|
160
|
+
const into = context.task.baseBranch ?? (await this.git.defaultBranch(context.root));
|
|
161
|
+
if (!into || into === branch) {
|
|
162
|
+
throw new GitError(`SOJA does not know which branch ${branch} should be merged into.`, {
|
|
163
|
+
suggestions: ['Create a `main` branch, or merge it yourself with `git merge`.'],
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
const [merged, files] = await Promise.all([
|
|
167
|
+
this.git.isMergedInto(context.root, branch, into),
|
|
168
|
+
this.git.changedFiles(context.root),
|
|
169
|
+
]);
|
|
170
|
+
return { ...context, branch, into, alreadyMerged: merged, uncommitted: files.length };
|
|
171
|
+
}
|
|
172
|
+
/** Merges the task branch into its base with a merge commit. */
|
|
173
|
+
async merge(session, target, options) {
|
|
174
|
+
const plan = await this.mergePlan(session, target, options.cwd);
|
|
175
|
+
if (plan.alreadyMerged) {
|
|
176
|
+
throw new GitError(`${plan.branch} is already merged into ${plan.into}.`, { suggestions: ['You can delete the branch (D).'] });
|
|
177
|
+
}
|
|
178
|
+
if (plan.uncommitted > 0) {
|
|
179
|
+
throw new GitError(`${plural(plan.uncommitted, 'uncommitted change')} would get in the way of the merge.`, {
|
|
180
|
+
code: 'dirty_worktree',
|
|
181
|
+
suggestions: ['Commit them first (C), or run `git stash`.'],
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
const ref = formatTaskRef(plan.task.number);
|
|
185
|
+
let hash;
|
|
186
|
+
try {
|
|
187
|
+
hash = await this.git.merge(plan.root, plan.branch, plan.into, `Merge ${ref}: ${plan.task.title}`);
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
if (error instanceof GitError && error.code === 'merge_conflict') {
|
|
191
|
+
const conflicts = (await this.git.changedFiles(plan.root)).filter((file) => file.kind === 'conflicted');
|
|
192
|
+
throw new GitError(`Merging ${plan.branch} into ${plan.into} has conflicts in ${plural(conflicts.length, 'file')}.`, {
|
|
193
|
+
code: 'merge_conflict',
|
|
194
|
+
suggestions: [
|
|
195
|
+
...conflicts.slice(0, 5).map((file) => `conflict: ${file.path}`),
|
|
196
|
+
...error.suggestions,
|
|
197
|
+
],
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
await this.record(session, plan.task, { type: 'git_merged', metadata: { branch: plan.branch, into: plan.into, hash } });
|
|
203
|
+
return { hash, branch: plan.branch, into: plan.into };
|
|
204
|
+
}
|
|
205
|
+
async abortMerge(session, target, cwd) {
|
|
206
|
+
const context = await this.context(session, target, cwd);
|
|
207
|
+
if (!(await this.git.isMerging(context.root)))
|
|
208
|
+
throw new GitError('There is no merge in progress.');
|
|
209
|
+
await this.git.abortMerge(context.root);
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Deletes the task branch (switching to its base first if it is checked
|
|
213
|
+
* out) and forgets it on the task. Unmerged branches need `force`.
|
|
214
|
+
*/
|
|
215
|
+
async deleteBranch(session, target, options) {
|
|
216
|
+
const context = await this.context(session, target, options.cwd);
|
|
217
|
+
const branch = this.requireBranch(context);
|
|
218
|
+
const exists = await this.git.branchExists(context.root, branch);
|
|
219
|
+
const into = context.task.baseBranch ?? (await this.git.defaultBranch(context.root));
|
|
220
|
+
const merged = exists && into !== null && into !== branch ? await this.git.isMergedInto(context.root, branch, into) : false;
|
|
221
|
+
let switchedTo = null;
|
|
222
|
+
if (exists) {
|
|
223
|
+
if (!merged && !options.force) {
|
|
224
|
+
throw new GitError(`${branch} has commits that are not in ${into ?? 'any base branch'}.`, {
|
|
225
|
+
code: 'not_merged',
|
|
226
|
+
suggestions: ['Merge it first (M), or confirm deleting it anyway. Those commits would be lost.'],
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
if (context.currentBranch === branch) {
|
|
230
|
+
if (!into || into === branch) {
|
|
231
|
+
throw new GitError(`${branch} is checked out and SOJA does not know where to switch.`, {
|
|
232
|
+
suggestions: ['Switch to another branch with `git switch`, then try again.'],
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
if ((await this.git.changedFiles(context.root)).length > 0) {
|
|
236
|
+
throw new GitError(`${branch} is checked out and has uncommitted changes.`, {
|
|
237
|
+
code: 'dirty_worktree',
|
|
238
|
+
suggestions: ['Commit them first (C), or run `git stash`.'],
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
await this.git.switchBranch(context.root, into);
|
|
242
|
+
switchedTo = into;
|
|
243
|
+
}
|
|
244
|
+
// Merged was verified against the base above, so -D is safe; -d would compare with HEAD instead.
|
|
245
|
+
await this.git.deleteBranch(context.root, branch, true);
|
|
246
|
+
}
|
|
247
|
+
await this.tasks.recordGitEvent(session, context.task, { type: 'git_branch_deleted', metadata: { branch, merged } }, { branch: null, baseBranch: null, branchStart: null });
|
|
248
|
+
return { branch, merged, switchedTo };
|
|
249
|
+
}
|
|
250
|
+
/** Pushes the task branch to origin. */
|
|
251
|
+
async push(session, target, options) {
|
|
252
|
+
const context = await this.context(session, target, options.cwd);
|
|
253
|
+
const branch = this.requireBranch(context);
|
|
254
|
+
await this.requirePushable(context.root, branch);
|
|
255
|
+
await this.git.push(context.root, branch, { interactive: options.interactive ?? false });
|
|
256
|
+
await this.record(session, context.task, { type: 'git_pushed', metadata: { branch, remote: 'origin' } });
|
|
257
|
+
return { branch };
|
|
258
|
+
}
|
|
259
|
+
/** Pushes the branch and opens a pull request into its base with the GitHub CLI. */
|
|
260
|
+
async openPullRequest(session, target, options) {
|
|
261
|
+
const context = await this.context(session, target, options.cwd);
|
|
262
|
+
const branch = this.requireBranch(context);
|
|
263
|
+
await this.requirePushable(context.root, branch);
|
|
264
|
+
const base = context.task.baseBranch ?? (await this.git.defaultBranch(context.root));
|
|
265
|
+
if (!base || base === branch) {
|
|
266
|
+
throw new GitError('SOJA does not know the base branch for the pull request.', {
|
|
267
|
+
suggestions: ['Create it with `gh pr create --base <branch>`.'],
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
const interactive = options.interactive ?? false;
|
|
271
|
+
await this.git.push(context.root, branch, { interactive });
|
|
272
|
+
const ref = formatTaskRef(context.task.number);
|
|
273
|
+
const details = [
|
|
274
|
+
context.task.description,
|
|
275
|
+
context.task.requester ? `Requested by: ${context.task.requester}` : null,
|
|
276
|
+
`SOJA task: ${ref}`,
|
|
277
|
+
].filter(Boolean);
|
|
278
|
+
const url = await this.git.createPullRequest(context.root, { base, head: branch, title: `${context.task.title} (${ref})`, body: details.join('\n\n') }, { interactive });
|
|
279
|
+
await this.record(session, context.task, { type: 'pr_opened', metadata: { url } });
|
|
280
|
+
return { url, base, branch };
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Finds open tasks whose branch was merged into its base outside SOJA
|
|
284
|
+
* (by hand, by an agent, or on GitHub followed by a pull) and marks them
|
|
285
|
+
* Done. Also reports branches that were deleted with no sign of a merge.
|
|
286
|
+
* Local and read-only on the repository; unreachable repositories are skipped.
|
|
287
|
+
*/
|
|
288
|
+
async detectMerges(session, cwd, options = {}) {
|
|
289
|
+
const candidates = options.only
|
|
290
|
+
? [await this.tasks.get(session, options.only)]
|
|
291
|
+
: await this.tasks.list(session, 'all');
|
|
292
|
+
const results = [];
|
|
293
|
+
for (const task of candidates) {
|
|
294
|
+
const branch = task.branch;
|
|
295
|
+
if (!branch || isClosed(task.status))
|
|
296
|
+
continue;
|
|
297
|
+
const found = await this.checkMerged(session, { ...task, branch }, cwd).catch(() => null);
|
|
298
|
+
if (!found)
|
|
299
|
+
continue;
|
|
300
|
+
if (found.kind === 'deleted') {
|
|
301
|
+
results.push({ kind: 'deleted', task, branch });
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
await this.record(session, task, {
|
|
305
|
+
type: 'git_merge_detected',
|
|
306
|
+
metadata: { branch, into: found.into, hash: found.hash },
|
|
307
|
+
});
|
|
308
|
+
const done = await this.tasks.complete(session, task);
|
|
309
|
+
results.push({ kind: 'merged', task: done, branch, into: found.into });
|
|
310
|
+
}
|
|
311
|
+
return results;
|
|
312
|
+
}
|
|
313
|
+
/** Forgets a task's branch without touching Git (e.g. it was deleted and will not come back). */
|
|
314
|
+
async forgetBranch(session, target) {
|
|
315
|
+
return this.tasks.update(session, target, { branch: null, baseBranch: null, branchStart: null });
|
|
316
|
+
}
|
|
317
|
+
// ── Pull requests on GitHub (v0.6, through the GitHub CLI) ──────────────
|
|
318
|
+
/** The task's pull request, fresh from GitHub. */
|
|
319
|
+
async pullRequest(session, target, cwd) {
|
|
320
|
+
try {
|
|
321
|
+
const task = await this.tasks.get(session, target);
|
|
322
|
+
if (!task.branch)
|
|
323
|
+
return { status: 'none', reason: 'No branch yet.' };
|
|
324
|
+
const { root, linkProject } = await this.repositoryFor(session, task, cwd);
|
|
325
|
+
if (linkProject)
|
|
326
|
+
return { status: 'none', reason: `${linkProject.name} has no repository linked.` };
|
|
327
|
+
const pr = await this.git.pullRequestFor(root, task.branch);
|
|
328
|
+
return pr ? { status: 'found', pr } : { status: 'none', reason: 'No pull request yet.' };
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
if (error instanceof GitError)
|
|
332
|
+
return { status: 'unavailable', reason: error.message, hint: error.hint, code: error.code };
|
|
333
|
+
if (error instanceof SojaError)
|
|
334
|
+
return { status: 'unavailable', reason: error.message, hint: error.hint, code: 'failed' };
|
|
335
|
+
throw error;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Pull requests of open tasks, by task id (for markers in lists). Uses one
|
|
340
|
+
* `gh pr list` per repository, reused for a minute. Unreachable
|
|
341
|
+
* repositories and GitHub problems are skipped silently.
|
|
342
|
+
*/
|
|
343
|
+
async pullRequestIndex(session, cwd, tasks) {
|
|
344
|
+
const index = new Map();
|
|
345
|
+
for (const [root, group] of await this.byRepository(session, cwd, tasks)) {
|
|
346
|
+
const list = await this.cachedPullRequests(root).catch(() => null);
|
|
347
|
+
if (!list)
|
|
348
|
+
continue;
|
|
349
|
+
for (const task of group) {
|
|
350
|
+
const pr = list.find((candidate) => candidate.head === task.branch);
|
|
351
|
+
if (pr)
|
|
352
|
+
index.set(task.id, pr);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return index;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Learns what happened on GitHub to open tasks: a merged pull request
|
|
359
|
+
* closes its task (`pr_merged`), and failing checks on a new commit are
|
|
360
|
+
* recorded once (`pr_checks_failed`), so in remote mode the whole team
|
|
361
|
+
* sees them. `only` checks one task with fresh data.
|
|
362
|
+
*/
|
|
363
|
+
async followPullRequests(session, cwd, options = {}) {
|
|
364
|
+
const candidates = (options.only ? [await this.tasks.get(session, options.only)] : await this.tasks.list(session, 'all')).filter((task) => task.branch && !isClosed(task.status));
|
|
365
|
+
const updates = [];
|
|
366
|
+
for (const [root, group] of await this.byRepository(session, cwd, candidates)) {
|
|
367
|
+
let list;
|
|
368
|
+
try {
|
|
369
|
+
const only = options.only ? group[0] : undefined;
|
|
370
|
+
list = only?.branch ? [await this.git.pullRequestFor(root, only.branch)].filter((pr) => pr !== null) : await this.cachedPullRequests(root);
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
continue; // gh missing, logged out or offline: nothing to learn right now
|
|
374
|
+
}
|
|
375
|
+
for (const task of group) {
|
|
376
|
+
const pr = list.find((candidate) => candidate.head === task.branch);
|
|
377
|
+
if (!pr)
|
|
378
|
+
continue;
|
|
379
|
+
// Acted on once per pull request: a task reopened after its merge stays open.
|
|
380
|
+
if (pr.state === 'merged' && !(await this.recorded(session, task, (event) => event.type === 'pr_merged' && event.metadata.number === pr.number))) {
|
|
381
|
+
await this.record(session, task, { type: 'pr_merged', metadata: { number: pr.number, url: pr.url, into: pr.base, via: 'github' } });
|
|
382
|
+
updates.push({ kind: 'merged', task: await this.tasks.complete(session, task), pr });
|
|
383
|
+
}
|
|
384
|
+
else if (pr.state === 'open' &&
|
|
385
|
+
pr.checks.failed > 0 &&
|
|
386
|
+
pr.headSha &&
|
|
387
|
+
!(await this.recorded(session, task, (event) => event.type === 'pr_checks_failed' && event.metadata.sha === pr.headSha))) {
|
|
388
|
+
await this.record(session, task, {
|
|
389
|
+
type: 'pr_checks_failed',
|
|
390
|
+
metadata: { number: pr.number, url: pr.url, sha: pr.headSha, checks: pr.checks.failing.slice(0, 20) },
|
|
391
|
+
});
|
|
392
|
+
updates.push({ kind: 'checks_failed', task, pr });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return updates;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Merges the task's pull request on GitHub (merge commit), closes the task
|
|
400
|
+
* and, if asked, deletes the branch on GitHub and here.
|
|
401
|
+
*/
|
|
402
|
+
async mergePullRequest(session, target, options) {
|
|
403
|
+
const context = await this.context(session, target, options.cwd);
|
|
404
|
+
const branch = this.requireBranch(context);
|
|
405
|
+
const pr = await this.git.pullRequestFor(context.root, branch);
|
|
406
|
+
if (!pr)
|
|
407
|
+
throw new GitError(`${branch} has no pull request.`, { suggestions: ['Open one from the Git menu (g).'] });
|
|
408
|
+
if (pr.state !== 'open')
|
|
409
|
+
throw new GitError(`Pull request #${pr.number} is already ${pr.state}.`);
|
|
410
|
+
if (pr.draft)
|
|
411
|
+
throw new GitError(`Pull request #${pr.number} is a draft.`, { suggestions: ['Mark it ready for review on GitHub first.'] });
|
|
412
|
+
// gh may switch away from the branch before deleting it; the working tree must be clean for that.
|
|
413
|
+
if (options.deleteBranch && context.currentBranch === branch && (await this.git.changedFiles(context.root)).length > 0) {
|
|
414
|
+
throw new GitError(`${branch} is checked out and has uncommitted changes.`, {
|
|
415
|
+
code: 'dirty_worktree',
|
|
416
|
+
suggestions: ['Commit them first (C), or merge without deleting the branch.'],
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
await this.git.mergePullRequest(context.root, pr.number, { deleteBranch: options.deleteBranch });
|
|
420
|
+
this.pullRequestCache.delete(context.root);
|
|
421
|
+
await this.record(session, context.task, { type: 'pr_merged', metadata: { number: pr.number, url: pr.url, into: pr.base, via: 'soja' } });
|
|
422
|
+
if (options.deleteBranch) {
|
|
423
|
+
await this.tasks.recordGitEvent(session, context.task, { type: 'git_branch_deleted', metadata: { branch, merged: true } }, { branch: null, baseBranch: null, branchStart: null });
|
|
424
|
+
}
|
|
425
|
+
const task = await this.tasks.complete(session, context.task);
|
|
426
|
+
return { pr: { ...pr, state: 'merged', mergedAt: new Date(this.now()) }, task };
|
|
427
|
+
}
|
|
428
|
+
/** Runs `gh auth login` in the terminal (the caller releases it first). */
|
|
429
|
+
loginGitHub() {
|
|
430
|
+
return this.git.loginGitHub();
|
|
431
|
+
}
|
|
432
|
+
/** Git context for the task detail. Problems are reported, never thrown. */
|
|
433
|
+
async inspect(session, target, cwd) {
|
|
434
|
+
try {
|
|
435
|
+
const plan = await this.plan(session, target, cwd);
|
|
436
|
+
if (plan.linkProject) {
|
|
437
|
+
return {
|
|
438
|
+
status: 'unavailable',
|
|
439
|
+
reason: `${plan.linkProject.name} has no repository linked.`,
|
|
440
|
+
hint: `Press b to link ${plan.root} and start, or r in Projects.`,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
const [own, mentions] = await Promise.all([
|
|
444
|
+
plan.branchExists ? this.git.commitsOnlyOn(plan.root, plan.branch, COMMIT_LIMIT) : Promise.resolve([]),
|
|
445
|
+
this.git.commitsMatching(plan.root, taskMentionPattern(plan.task.number), COMMIT_LIMIT),
|
|
446
|
+
]);
|
|
447
|
+
return {
|
|
448
|
+
status: 'ready',
|
|
449
|
+
root: plan.root,
|
|
450
|
+
branch: plan.branch,
|
|
451
|
+
recorded: plan.task.branch !== null,
|
|
452
|
+
branchExists: plan.branchExists,
|
|
453
|
+
checkedOut: plan.checkedOut,
|
|
454
|
+
currentBranch: plan.currentBranch,
|
|
455
|
+
uncommitted: plan.uncommitted,
|
|
456
|
+
commits: mergeCommits(own, mentions).slice(0, COMMIT_LIMIT),
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
if (error instanceof SojaError)
|
|
461
|
+
return { status: 'unavailable', reason: error.message, hint: error.hint };
|
|
462
|
+
throw error;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/** Repository and branch facts for operations on an already-linked task. */
|
|
466
|
+
async context(session, target, cwd) {
|
|
467
|
+
const task = await this.tasks.get(session, target);
|
|
468
|
+
const { root, linkProject } = await this.repositoryFor(session, task, cwd);
|
|
469
|
+
if (linkProject) {
|
|
470
|
+
throw new GitError(`${linkProject.name} has no repository linked.`, {
|
|
471
|
+
suggestions: ['Press b to link it and start the task, or r in Projects.'],
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
return { task, root, currentBranch: await this.git.currentBranch(root) };
|
|
475
|
+
}
|
|
476
|
+
requireBranch(context) {
|
|
477
|
+
if (!context.task.branch) {
|
|
478
|
+
throw new GitError(`${formatTaskRef(context.task.number)} has no branch yet.`, {
|
|
479
|
+
suggestions: ['Press b (or run `soja start`) to create it.'],
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
return context.task.branch;
|
|
483
|
+
}
|
|
484
|
+
requireOnTaskBranch(context) {
|
|
485
|
+
const branch = this.requireBranch(context);
|
|
486
|
+
if (context.currentBranch !== branch) {
|
|
487
|
+
throw new GitError(`You are on ${context.currentBranch ?? 'a detached HEAD'}, not on ${branch}.`, {
|
|
488
|
+
code: 'wrong_branch',
|
|
489
|
+
suggestions: [`Press b to switch to ${branch} first, so the commit lands on the task.`],
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
async requirePushable(root, branch) {
|
|
494
|
+
if (!(await this.git.branchExists(root, branch))) {
|
|
495
|
+
throw new GitError(`${branch} does not exist in this repository.`, { suggestions: ['Press b to create it again.'] });
|
|
496
|
+
}
|
|
497
|
+
if (!(await this.git.originUrl(root))) {
|
|
498
|
+
throw new GitError('This repository has no `origin` remote.', {
|
|
499
|
+
code: 'no_remote',
|
|
500
|
+
suggestions: ['Add one: `git remote add origin <url>`.'],
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
cachedPullRequests(root) {
|
|
505
|
+
const cached = this.pullRequestCache.get(root);
|
|
506
|
+
if (cached && this.now() - cached.at < PR_CACHE_MS)
|
|
507
|
+
return cached.list;
|
|
508
|
+
const list = this.git.pullRequests(root, PR_LIST_LIMIT);
|
|
509
|
+
this.pullRequestCache.set(root, { at: this.now(), list });
|
|
510
|
+
// A failure is not remembered: the next look tries again.
|
|
511
|
+
list.catch(() => this.pullRequestCache.delete(root));
|
|
512
|
+
return list;
|
|
513
|
+
}
|
|
514
|
+
/** Tasks with a branch, grouped by the repository they live in. Tasks without one are left out. */
|
|
515
|
+
async byRepository(session, cwd, tasks) {
|
|
516
|
+
const groups = new Map();
|
|
517
|
+
for (const task of tasks) {
|
|
518
|
+
if (!task.branch)
|
|
519
|
+
continue;
|
|
520
|
+
const found = await this.repositoryFor(session, task, cwd).catch(() => null);
|
|
521
|
+
if (!found || found.linkProject)
|
|
522
|
+
continue;
|
|
523
|
+
groups.set(found.root, [...(groups.get(found.root) ?? []), task]);
|
|
524
|
+
}
|
|
525
|
+
return groups;
|
|
526
|
+
}
|
|
527
|
+
async recorded(session, task, matches) {
|
|
528
|
+
const details = await this.tasks.get(session, task);
|
|
529
|
+
return details.timeline.some((entry) => entry.kind === 'event' && matches(entry.event));
|
|
530
|
+
}
|
|
531
|
+
async record(session, task, event) {
|
|
532
|
+
await this.tasks.recordGitEvent(session, task, event);
|
|
533
|
+
}
|
|
534
|
+
async checkMerged(session, task, cwd) {
|
|
535
|
+
const { root, linkProject } = await this.repositoryFor(session, task, cwd);
|
|
536
|
+
if (linkProject)
|
|
537
|
+
return null;
|
|
538
|
+
const into = task.baseBranch ?? (await this.git.defaultBranch(root));
|
|
539
|
+
if (!into || into === task.branch)
|
|
540
|
+
return null;
|
|
541
|
+
const exists = await this.git.branchExists(root, task.branch);
|
|
542
|
+
// A branch that still exists counts as merged only if it has work of its own
|
|
543
|
+
// and all of it is in the base. A fresh branch is trivially "contained".
|
|
544
|
+
if (exists && task.branchStart) {
|
|
545
|
+
const own = await this.git.countCommits(root, task.branchStart, `refs/heads/${task.branch}`);
|
|
546
|
+
if (own > 0 && (await this.git.isMergedInto(root, task.branch, into)))
|
|
547
|
+
return { kind: 'merged', into, hash: null };
|
|
548
|
+
}
|
|
549
|
+
const commits = await this.git.recentCommits(root, into, { since: task.branchStart, limit: 300 });
|
|
550
|
+
const hash = findMergeEvidence(commits, { branch: task.branch, ref: formatTaskRef(task.number) });
|
|
551
|
+
if (hash)
|
|
552
|
+
return { kind: 'merged', into, hash };
|
|
553
|
+
return exists ? null : { kind: 'deleted' };
|
|
554
|
+
}
|
|
555
|
+
async repositoryFor(session, task, cwd) {
|
|
556
|
+
const ref = formatTaskRef(task.number);
|
|
557
|
+
const project = task.projectId ? await this.projects.get(session, task.projectId).catch(() => null) : null;
|
|
558
|
+
if (project?.repositoryPath) {
|
|
559
|
+
const path = project.repositoryPath;
|
|
560
|
+
const relink = { hint: `Relink it: soja project link ${project.key} <path>` };
|
|
561
|
+
if (!existsSync(path))
|
|
562
|
+
throw new GitError(`${project.name}'s repository is gone: ${path}`, relink);
|
|
563
|
+
const root = await this.git.repositoryRoot(path);
|
|
564
|
+
if (!root)
|
|
565
|
+
throw new GitError(`${path} is no longer a Git repository.`, relink);
|
|
566
|
+
return { root, linkProject: null };
|
|
567
|
+
}
|
|
568
|
+
const cwdRoot = await this.git.repositoryRoot(cwd);
|
|
569
|
+
if (project) {
|
|
570
|
+
if (!cwdRoot) {
|
|
571
|
+
throw new GitError(`${project.name} has no repository linked.`, {
|
|
572
|
+
hint: `Run \`soja project link ${project.key} <path>\`, or start from inside its repository.`,
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
return { root: cwdRoot, linkProject: project };
|
|
576
|
+
}
|
|
577
|
+
if (!cwdRoot) {
|
|
578
|
+
throw new GitError(`${ref} has no project and this directory is not a Git repository.`, {
|
|
579
|
+
hint: `Run it inside a repository, or use \`soja task start ${ref}\` to start without Git.`,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
return { root: cwdRoot, linkProject: null };
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
function mergeCommits(...lists) {
|
|
586
|
+
const byHash = new Map();
|
|
587
|
+
for (const commit of lists.flat())
|
|
588
|
+
byHash.set(commit.hash, commit);
|
|
589
|
+
return [...byHash.values()].sort((a, b) => b.date.getTime() - a.date.getTime());
|
|
590
|
+
}
|
|
591
|
+
function plural(count, noun) {
|
|
592
|
+
return `${count} ${noun}${count === 1 ? '' : 's'}`;
|
|
593
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { GitConsole } from '../../git/console.js';
|
|
2
|
+
import { FolderService } from './folder-service.js';
|
|
3
|
+
import { GitWorkflowService } from './git-workflow-service.js';
|
|
4
|
+
import { ProjectService } from './project-service.js';
|
|
5
|
+
import { SessionService } from './session-service.js';
|
|
6
|
+
import { TaskService } from './task-service.js';
|
|
7
|
+
import { WorkspaceService } from './workspace-service.js';
|
|
8
|
+
export function createServices(repos, config, options) {
|
|
9
|
+
const workspaces = new WorkspaceService(repos, config);
|
|
10
|
+
const projects = new ProjectService(repos, options.git);
|
|
11
|
+
const tasks = new TaskService(repos, options.clock);
|
|
12
|
+
return {
|
|
13
|
+
session: new SessionService(repos, config, workspaces),
|
|
14
|
+
workspaces,
|
|
15
|
+
projects,
|
|
16
|
+
tasks,
|
|
17
|
+
git: new GitWorkflowService(tasks, projects, options.git),
|
|
18
|
+
folders: new FolderService(config),
|
|
19
|
+
gitConsole: options.gitConsole ?? new GitConsole(),
|
|
20
|
+
environment: { mode: 'local' },
|
|
21
|
+
backups: options.backups,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export { pullRequestWarnings } from './git-workflow-service.js';
|