@oh-my-pi/pi-git-tool 3.20.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.
@@ -0,0 +1,93 @@
1
+ import type { FileStatus, StatusResult } from "../types";
2
+
3
+ function mapStatus(code: string, path: string, oldPath?: string): FileStatus {
4
+ const statusMap: Record<string, FileStatus["status"]> = {
5
+ A: "added",
6
+ M: "modified",
7
+ D: "deleted",
8
+ R: "renamed",
9
+ C: "copied",
10
+ T: "modified",
11
+ U: "modified",
12
+ };
13
+ return { path, status: statusMap[code] ?? "modified", oldPath };
14
+ }
15
+
16
+ export function parseStatus(output: string, includeIgnored: boolean): StatusResult {
17
+ const lines = output.split("\n");
18
+ const result: StatusResult = {
19
+ branch: "",
20
+ upstream: null,
21
+ ahead: 0,
22
+ behind: 0,
23
+ staged: [],
24
+ modified: [],
25
+ untracked: [],
26
+ conflicts: [],
27
+ };
28
+ if (includeIgnored) {
29
+ result.ignored = [];
30
+ }
31
+
32
+ for (const line of lines) {
33
+ if (line.startsWith("# branch.head ")) {
34
+ result.branch = line.slice(14).trim();
35
+ continue;
36
+ }
37
+ if (line.startsWith("# branch.upstream ")) {
38
+ result.upstream = line.slice(18).trim();
39
+ continue;
40
+ }
41
+ if (line.startsWith("# branch.ab ")) {
42
+ const match = line.match(/\+(\d+) -?(\d+)/);
43
+ if (match) {
44
+ result.ahead = Number.parseInt(match[1], 10);
45
+ result.behind = Number.parseInt(match[2] ?? "0", 10);
46
+ }
47
+ continue;
48
+ }
49
+
50
+ if (line.startsWith("1 ") || line.startsWith("2 ")) {
51
+ const parts = line.split(" ");
52
+ const xy = parts[1];
53
+ let path = parts.slice(-1)[0] ?? "";
54
+ let oldPath: string | undefined;
55
+
56
+ if (line.startsWith("2 ")) {
57
+ const [beforeTab, afterTab] = line.split("\t");
58
+ if (afterTab) {
59
+ const preParts = beforeTab.split(" ");
60
+ path = preParts[preParts.length - 1];
61
+ oldPath = afterTab.trim();
62
+ }
63
+ }
64
+
65
+ if (xy[0] !== ".") {
66
+ result.staged.push(mapStatus(xy[0], path, oldPath));
67
+ }
68
+ if (xy[1] !== ".") {
69
+ result.modified.push(mapStatus(xy[1], path, oldPath));
70
+ }
71
+ continue;
72
+ }
73
+
74
+ if (line.startsWith("u ")) {
75
+ const path = line.split(" ").slice(-1)[0];
76
+ if (path) {
77
+ result.conflicts.push(path);
78
+ }
79
+ continue;
80
+ }
81
+
82
+ if (line.startsWith("? ")) {
83
+ result.untracked.push(line.slice(2));
84
+ continue;
85
+ }
86
+
87
+ if (includeIgnored && line.startsWith("! ")) {
88
+ result.ignored?.push(line.slice(2));
89
+ }
90
+ }
91
+
92
+ return result;
93
+ }
package/src/render.ts ADDED
@@ -0,0 +1,181 @@
1
+ import type {
2
+ AddResult,
3
+ BlameResult,
4
+ BranchListResult,
5
+ CherryPickResult,
6
+ CommitResult,
7
+ DiffResult,
8
+ FetchResult,
9
+ LogResult,
10
+ MergeResult,
11
+ PullResult,
12
+ PushResult,
13
+ RebaseResult,
14
+ ReleaseResult,
15
+ RestoreResult,
16
+ ShowCommitResult,
17
+ ShowFileResult,
18
+ StatusResult,
19
+ TagResult,
20
+ } from "./types";
21
+
22
+ function renderFileList(title: string, files: string[]): string {
23
+ if (files.length === 0) return "";
24
+ return `${title}:\n${files.map((file) => `- ${file}`).join("\n")}`;
25
+ }
26
+
27
+ export function renderStatus(result: StatusResult): string {
28
+ const parts: string[] = [];
29
+ const sync = result.upstream
30
+ ? `(upstream ${result.upstream}, ahead ${result.ahead}, behind ${result.behind})`
31
+ : "(no upstream)";
32
+ parts.push(`Branch: ${result.branch} ${sync}`);
33
+
34
+ if (result.staged.length > 0) {
35
+ parts.push(
36
+ `Staged (${result.staged.length}):\n${result.staged
37
+ .map((file) => `- ${file.path} (${file.status})`)
38
+ .join("\n")}`,
39
+ );
40
+ }
41
+ if (result.modified.length > 0) {
42
+ parts.push(
43
+ `Modified (${result.modified.length}):\n${result.modified
44
+ .map((file) => `- ${file.path} (${file.status})`)
45
+ .join("\n")}`,
46
+ );
47
+ }
48
+ if (result.untracked.length > 0) {
49
+ parts.push(renderFileList(`Untracked (${result.untracked.length})`, result.untracked));
50
+ }
51
+ if (result.conflicts.length > 0) {
52
+ parts.push(renderFileList(`Conflicts (${result.conflicts.length})`, result.conflicts));
53
+ }
54
+ if (result.ignored && result.ignored.length > 0) {
55
+ parts.push(renderFileList(`Ignored (${result.ignored.length})`, result.ignored));
56
+ }
57
+
58
+ return parts.join("\n\n");
59
+ }
60
+
61
+ export function renderDiff(result: DiffResult): string {
62
+ const header = `${result.stats.filesChanged} files changed, ${result.stats.insertions} insertions(+), ${result.stats.deletions} deletions(-)`;
63
+ const files = result.files.map((file) => {
64
+ const status = file.status.toUpperCase();
65
+ return `- ${status} ${file.path} (+${file.additions}/-${file.deletions})`;
66
+ });
67
+ const truncatedNote = result.truncated ? "\n\nDiff output truncated." : "";
68
+ return [header, ...files].join("\n") + truncatedNote;
69
+ }
70
+
71
+ export function renderLog(result: LogResult): string {
72
+ if (result.commits.length === 0) return "No commits found.";
73
+ const lines = result.commits.map((commit) => `${commit.shortSha} ${commit.subject}`);
74
+ const more = result.hasMore ? "\n(more commits available)" : "";
75
+ return lines.join("\n") + more;
76
+ }
77
+
78
+ export function renderShowCommit(result: ShowCommitResult): string {
79
+ const commit = result.commit;
80
+ const header = `${commit.shortSha} ${commit.subject}`;
81
+ if (!result.diff) return header;
82
+ return `${header}\n\n${renderDiff(result.diff)}`;
83
+ }
84
+
85
+ export function renderShowFile(result: ShowFileResult): string {
86
+ const truncated = result.truncated ? "\n\n[truncated]" : "";
87
+ return `${result.content}${truncated}`;
88
+ }
89
+
90
+ export function renderBlame(result: BlameResult): string {
91
+ return result.lines.map((line) => `${line.shortSha} ${line.lineNo} ${line.author}: ${line.content}`).join("\n");
92
+ }
93
+
94
+ export function renderBranchList(result: BranchListResult): string {
95
+ const lines: string[] = [];
96
+ lines.push(`Current: ${result.current}`);
97
+ lines.push("Local:");
98
+ for (const branch of result.local) {
99
+ const track = branch.upstream
100
+ ? ` (${branch.upstream} ahead ${branch.ahead ?? 0} behind ${branch.behind ?? 0}${branch.gone ? ", gone" : ""})`
101
+ : "";
102
+ lines.push(`- ${branch.name}${track}`);
103
+ }
104
+ if (result.remote && result.remote.length > 0) {
105
+ lines.push("Remote:");
106
+ for (const branch of result.remote) {
107
+ lines.push(`- ${branch.name}`);
108
+ }
109
+ }
110
+ return lines.join("\n");
111
+ }
112
+
113
+ export function renderAdd(result: AddResult): string {
114
+ return `Staged ${result.staged.length} files`;
115
+ }
116
+
117
+ export function renderRestore(result: RestoreResult): string {
118
+ return `Restored ${result.restored.length} files`;
119
+ }
120
+
121
+ export function renderCommit(result: CommitResult): string {
122
+ return `${result.shortSha} ${result.subject}`;
123
+ }
124
+
125
+ export function renderMerge(result: MergeResult): string {
126
+ if (result.status === "conflict") {
127
+ return `Merge conflict${result.conflicts?.length ? `: ${result.conflicts.join(", ")}` : ""}`;
128
+ }
129
+ return `Merge ${result.status}`;
130
+ }
131
+
132
+ export function renderRebase(result: RebaseResult): string {
133
+ if (result.status === "conflict") {
134
+ return `Rebase conflict${result.conflicts?.length ? `: ${result.conflicts.join(", ")}` : ""}`;
135
+ }
136
+ return `Rebase ${result.status}`;
137
+ }
138
+
139
+ export function renderCherryPick(result: CherryPickResult): string {
140
+ if (result.status === "conflict") {
141
+ return `Cherry-pick conflict${result.conflicts?.length ? `: ${result.conflicts.join(", ")}` : ""}`;
142
+ }
143
+ return `Cherry-pick applied ${result.appliedCommits?.length ?? 0} commits`;
144
+ }
145
+
146
+ export function renderFetch(result: FetchResult): string {
147
+ const lines = result.updated.map((entry) => `- ${entry.ref} ${entry.oldSha} -> ${entry.newSha}`);
148
+ if (result.pruned && result.pruned.length > 0) {
149
+ lines.push(`Pruned: ${result.pruned.join(", ")}`);
150
+ }
151
+ return lines.length > 0 ? lines.join("\n") : "Fetch completed";
152
+ }
153
+
154
+ export function renderPull(result: PullResult): string {
155
+ if (result.status === "conflict") {
156
+ return `Pull conflict${result.conflicts?.length ? `: ${result.conflicts.join(", ")}` : ""}`;
157
+ }
158
+ if (result.status === "up-to-date") return "Already up to date.";
159
+ return `Pulled ${result.commits ?? 0} commits`;
160
+ }
161
+
162
+ export function renderPush(result: PushResult): string {
163
+ return `Pushed ${result.commits} commits to ${result.remote}/${result.branch}`;
164
+ }
165
+
166
+ export function renderRelease(result: ReleaseResult): string {
167
+ if ("releases" in result) {
168
+ return `Releases: ${result.releases.length}`;
169
+ }
170
+ if ("tag" in result) {
171
+ return `Release ${result.tag}`;
172
+ }
173
+ return "Release operation completed";
174
+ }
175
+
176
+ export function renderTag(result: TagResult): string {
177
+ if ("tags" in result) {
178
+ return `Tags: ${result.tags.length}`;
179
+ }
180
+ return "Tag operation completed";
181
+ }
@@ -0,0 +1,144 @@
1
+ import type { Operation, SafetyCheck, SafetyResult } from "../types";
2
+ import { git } from "../utils";
3
+ import { defaultPolicy, isProtectedBranch } from "./policies";
4
+
5
+ const sessionCommits = new Set<string>();
6
+
7
+ export function markCommitCreated(sha: string): void {
8
+ sessionCommits.add(sha);
9
+ }
10
+
11
+ async function getCurrentBranch(cwd?: string): Promise<string> {
12
+ const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], { cwd });
13
+ if (result.error) return "";
14
+ return result.stdout.trim();
15
+ }
16
+
17
+ async function getHeadSha(cwd?: string): Promise<string> {
18
+ const result = await git(["rev-parse", "HEAD"], { cwd });
19
+ if (result.error) return "";
20
+ return result.stdout.trim();
21
+ }
22
+
23
+ async function getBranchSync(cwd?: string): Promise<{ upstream: string | null; ahead: number; behind: number }> {
24
+ const result = await git(["status", "--porcelain=v2", "--branch", "--ahead-behind"], { cwd });
25
+ if (result.error) return { upstream: null, ahead: 0, behind: 0 };
26
+
27
+ let upstream: string | null = null;
28
+ let ahead = 0;
29
+ let behind = 0;
30
+ for (const line of result.stdout.split("\n")) {
31
+ if (line.startsWith("# branch.upstream ")) {
32
+ upstream = line.slice(18).trim();
33
+ }
34
+ if (line.startsWith("# branch.ab ")) {
35
+ const match = line.match(/\+(\d+) -?(\d+)/);
36
+ if (match) {
37
+ ahead = Number.parseInt(match[1], 10);
38
+ behind = Number.parseInt(match[2] ?? "0", 10);
39
+ }
40
+ }
41
+ }
42
+ return { upstream, ahead, behind };
43
+ }
44
+
45
+ async function isHeadPushed(cwd?: string): Promise<boolean> {
46
+ const sync = await getBranchSync(cwd);
47
+ if (!sync.upstream) return false;
48
+ return sync.ahead === 0;
49
+ }
50
+
51
+ function evaluateChecks(checks: SafetyCheck[]): SafetyResult {
52
+ const blocked = checks.find((check) => check.level === "block");
53
+ if (blocked) {
54
+ return {
55
+ blocked: true,
56
+ confirm: false,
57
+ message: blocked.message,
58
+ suggestion: blocked.suggestion,
59
+ override: blocked.override,
60
+ warnings: [],
61
+ };
62
+ }
63
+ const confirm = checks.find((check) => check.level === "confirm");
64
+ if (confirm) {
65
+ return {
66
+ blocked: false,
67
+ confirm: true,
68
+ message: confirm.message,
69
+ suggestion: confirm.suggestion,
70
+ override: confirm.override,
71
+ warnings: [],
72
+ };
73
+ }
74
+ const warnings = checks.filter((check) => check.level === "warn").map((check) => check.message);
75
+ return {
76
+ blocked: false,
77
+ confirm: false,
78
+ warnings,
79
+ };
80
+ }
81
+
82
+ export async function checkSafety(
83
+ operation: Operation,
84
+ params: Record<string, unknown>,
85
+ cwd?: string,
86
+ ): Promise<SafetyResult> {
87
+ const checks: SafetyCheck[] = [];
88
+
89
+ if (operation === "push" && params.force) {
90
+ const branch = (params.branch as string | undefined) ?? (await getCurrentBranch(cwd));
91
+ if (branch && isProtectedBranch(branch)) {
92
+ checks.push({
93
+ level: defaultPolicy.forcePushMain,
94
+ message: `Force push to protected branch '${branch}' is blocked`,
95
+ override: "force_override",
96
+ });
97
+ } else {
98
+ checks.push({
99
+ level: defaultPolicy.forcePush,
100
+ message: `Force push to '${branch || "current branch"}' will overwrite remote history`,
101
+ suggestion: "Consider using force_with_lease instead",
102
+ });
103
+ }
104
+ }
105
+
106
+ if (operation === "commit" && params.amend) {
107
+ const pushed = await isHeadPushed(cwd);
108
+ const headSha = await getHeadSha(cwd);
109
+ if (pushed && headSha && !sessionCommits.has(headSha)) {
110
+ checks.push({
111
+ level: defaultPolicy.amendPushed,
112
+ message: "Cannot amend: HEAD has been pushed to remote",
113
+ suggestion: "Create a new commit instead",
114
+ });
115
+ }
116
+ }
117
+
118
+ if (operation === "rebase") {
119
+ const pushed = await isHeadPushed(cwd);
120
+ if (pushed) {
121
+ checks.push({
122
+ level: defaultPolicy.rebasePushed,
123
+ message: "Cannot rebase: HEAD has been pushed to remote",
124
+ suggestion: "Merge instead of rebasing pushed commits",
125
+ });
126
+ }
127
+ }
128
+
129
+ if (operation === "restore" && params.worktree && !params.staged) {
130
+ checks.push({
131
+ level: defaultPolicy.discardChanges,
132
+ message: "Restoring worktree will discard local changes",
133
+ });
134
+ }
135
+
136
+ if (operation === "branch" && params.action === "delete") {
137
+ checks.push({
138
+ level: defaultPolicy.deleteBranch,
139
+ message: "Deleting a branch will remove its ref",
140
+ });
141
+ }
142
+
143
+ return evaluateChecks(checks);
144
+ }
@@ -0,0 +1,25 @@
1
+ import type { SafetyPolicy } from "../types";
2
+
3
+ export const defaultPolicy: SafetyPolicy = {
4
+ forcePush: "confirm",
5
+ forcePushMain: "block",
6
+ hardReset: "confirm",
7
+ discardChanges: "warn",
8
+ deleteBranch: "warn",
9
+ amendPushed: "block",
10
+ rebasePushed: "block",
11
+ };
12
+
13
+ export const defaultProtectedBranches = ["main", "master", "develop", "release/*"];
14
+
15
+ export function isProtectedBranch(branch: string): boolean {
16
+ return defaultProtectedBranches.some((pattern) => {
17
+ if (pattern.endsWith("/")) {
18
+ return branch.startsWith(pattern);
19
+ }
20
+ if (pattern.endsWith("/*")) {
21
+ return branch.startsWith(pattern.slice(0, -1));
22
+ }
23
+ return branch === pattern;
24
+ });
25
+ }