refactor(go): rename module and package from 'contexa' to 'cortexa'
Swadhin Biswas committed 7 months ago
76bac60
1 parent ff86ca4
commit 76bac60
5 files changed
+669 -172
Showing 5 changed files+669-172
▼GO/README.md
+10-10
| @@ -1,12 +1,12 @@ | ||
1 | -# contexa | |
1 | +# cortexa | |
2 | 2 | |
3 | -[](https://pkg.go.dev/github.com/swadhinbiswas/contexa/GO) | |
3 | +[](https://pkg.go.dev/github.com/swadhinbiswas/Cortexa/GO) | |
4 | 4 | [](LICENSE) |
5 | -[](https://github.com/swadhinbiswas/contexa) | |
5 | +[](https://github.com/swadhinbiswas/Cortexa) | |
6 | 6 | |
7 | 7 | **Git-inspired context management for LLM agents.** COMMIT, BRANCH, MERGE, and CONTEXT operations over a persistent versioned memory workspace. |
8 | 8 | |
9 | -Go implementation of the **contexa** framework. | |
9 | +Go implementation of the **cortexa** framework. | |
10 | 10 | |
11 | 11 | Based on: [arXiv:2508.00031](https://arxiv.org/abs/2508.00031) -- *"Git Context Controller: Manage the Context of LLM-based Agents like Git"* (Junde Wu et al., 2025) |
12 | 12 | |
| @@ -15,7 +15,7 @@ Based on: [arXiv:2508.00031](https://arxiv.org/abs/2508.00031) -- *"Git Context | ||
15 | 15 | ## Installation |
16 | 16 | |
17 | 17 | ```bash |
18 | -go get github.com/swadhinbiswas/contexa/GO/cortexa | |
18 | +go get github.com/swadhinbiswas/Cortexa/GO/cortexa | |
19 | 19 | ``` |
20 | 20 | |
21 | 21 | --- |
| @@ -27,7 +27,7 @@ package main | ||
27 | 27 | |
28 | 28 | import ( |
29 | 29 | "fmt" |
30 | - "github.com/swadhinbiswas/contexa/GO/cortexa" | |
30 | + "github.com/swadhinbiswas/Cortexa/GO/cortexa" | |
31 | 31 | ) |
32 | 32 | |
33 | 33 | func main() { |
| @@ -175,8 +175,8 @@ All data is stored as **human-readable Markdown and YAML**. | ||
175 | 175 | ## Running Tests |
176 | 176 | |
177 | 177 | ```bash |
178 | -git clone https://github.com/swadhinbiswas/contexa.git | |
179 | -cd contexa/GO | |
178 | +git clone https://github.com/swadhinbiswas/Cortexa.git | |
179 | +cd cortexa/GO | |
180 | 180 | go test -v ./cortexa/ |
181 | 181 | ``` |
182 | 182 | |
| @@ -212,7 +212,7 @@ MIT License. See [LICENSE](LICENSE) for details. | ||
212 | 212 | |
213 | 213 | ## Links |
214 | 214 | |
215 | -- [GitHub Repository](https://github.com/swadhinbiswas/contexa) | |
216 | -- [pkg.go.dev](https://pkg.go.dev/github.com/swadhinbiswas/contexa/GO) | |
215 | +- [GitHub Repository](https://github.com/swadhinbiswas/Cortexa) | |
216 | +- [pkg.go.dev](https://pkg.go.dev/github.com/swadhinbiswas/Cortexa/GO) | |
217 | 217 | - [Original Paper](https://arxiv.org/abs/2508.00031) |
218 | 218 | - [Author: Swadhin Biswas](https://github.com/swadhinbiswas) |
219 | 219 | |
▼GO/cortexa/interop_test.go
+303-0
| @@ -0,0 +1,303 @@ | ||
1 | +package cortexa | |
2 | + | |
3 | +// interop_test.go — Cross-language interoperability tests. | |
4 | +// | |
5 | +// These tests verify that the on-disk .GCC/ format produced by the Go | |
6 | +// implementation matches the specification that all 7 language | |
7 | +// implementations (Python, JS/TS, Rust, Go, Zig, Lua, Elixir) agree on. | |
8 | +// | |
9 | +// They also verify that Go can read a workspace hand-crafted in the | |
10 | +// canonical format (simulating what any other language would produce). | |
11 | + | |
12 | +import ( | |
13 | + "os" | |
14 | + "path/filepath" | |
15 | + "strings" | |
16 | + "testing" | |
17 | +) | |
18 | + | |
19 | +// ------------------------------------------------------------------ // | |
20 | +// Format verification: Go writes → verify raw files // | |
21 | +// ------------------------------------------------------------------ // | |
22 | + | |
23 | +func TestInterop_OTASeparatorFormat(t *testing.T) { | |
24 | + ws, _ := setupWorkspace(t, "interop test") | |
25 | + ws.LogOTA("obs1", "thought1", "act1") | |
26 | + ws.LogOTA("obs2", "thought2", "act2") | |
27 | + | |
28 | + raw := ws.read(ws.logPath(mainBranch)) | |
29 | + | |
30 | + // OTA blocks MUST be separated by "---\n" (3 dashes), NOT "--------\n" | |
31 | + if strings.Contains(raw, "--------") { | |
32 | + t.Fatal("log.md must use '---' separator (3 dashes), not 8 dashes") | |
33 | + } | |
34 | + if !strings.Contains(raw, "\n---\n") { | |
35 | + t.Fatal("log.md must contain '---' separator between OTA blocks") | |
36 | + } | |
37 | + | |
38 | + // Headers must use em-dash: "### Step N — timestamp" | |
39 | + if strings.Contains(raw, "Step 1-") || strings.Contains(raw, "Step 2-") { | |
40 | + t.Fatal("OTA headers must use em-dash ' — ', not hyphen '-'") | |
41 | + } | |
42 | + if !strings.Contains(raw, " — ") { | |
43 | + t.Fatal("OTA headers must contain em-dash ' — '") | |
44 | + } | |
45 | +} | |
46 | + | |
47 | +func TestInterop_CommitSeparatorFormat(t *testing.T) { | |
48 | + ws, _ := setupWorkspace(t, "interop test") | |
49 | + ws.Commit("First milestone", nil, nil) | |
50 | + ws.Commit("Second milestone", nil, nil) | |
51 | + | |
52 | + raw := ws.read(ws.commitPath(mainBranch)) | |
53 | + | |
54 | + // Commit blocks MUST be separated by "---\n" | |
55 | + parts := strings.Split(raw, "\n---\n") | |
56 | + if len(parts) < 2 { | |
57 | + t.Fatalf("commit.md must have at least 2 blocks separated by '---', got %d", len(parts)) | |
58 | + } | |
59 | + | |
60 | + // Each commit must start with "## Commit `<id>`" (first block may have a header) | |
61 | + for i, part := range parts { | |
62 | + trimmed := strings.TrimSpace(part) | |
63 | + if trimmed == "" { | |
64 | + continue | |
65 | + } | |
66 | + // The first block may contain a "# Commit History" header before the commit | |
67 | + if i == 0 && strings.HasPrefix(trimmed, "# Commit History") { | |
68 | + // Check that the commit still appears after the header | |
69 | + if !strings.Contains(trimmed, "## Commit `") { | |
70 | + t.Errorf("first block has header but no commit: %q", trimmed[:min(80, len(trimmed))]) | |
71 | + } | |
72 | + continue | |
73 | + } | |
74 | + if !strings.HasPrefix(trimmed, "## Commit `") { | |
75 | + t.Errorf("commit block must start with '## Commit `<id>`', got: %q", | |
76 | + trimmed[:min(60, len(trimmed))]) | |
77 | + } | |
78 | + } | |
79 | +} | |
80 | + | |
81 | +func TestInterop_MetadataYAMLFormat(t *testing.T) { | |
82 | + ws, _ := setupWorkspace(t, "interop test") | |
83 | + ws.Branch("feature", "Test cross-lang metadata") | |
84 | + | |
85 | + raw, err := os.ReadFile(ws.metaPath("feature")) | |
86 | + if err != nil { | |
87 | + t.Fatalf("read metadata.yaml: %v", err) | |
88 | + } | |
89 | + | |
90 | + content := string(raw) | |
91 | + | |
92 | + // Must contain required YAML fields | |
93 | + for _, field := range []string{"name:", "purpose:", "created_from:", "created_at:", "status:"} { | |
94 | + if !strings.Contains(content, field) { | |
95 | + t.Errorf("metadata.yaml missing required field: %s", field) | |
96 | + } | |
97 | + } | |
98 | + | |
99 | + // Verify values | |
100 | + if !strings.Contains(content, "name: feature") { | |
101 | + t.Error("metadata.yaml name should be 'feature'") | |
102 | + } | |
103 | + if !strings.Contains(content, "status: active") { | |
104 | + t.Error("new branch metadata should have status: active") | |
105 | + } | |
106 | +} | |
107 | + | |
108 | +func TestInterop_MergeUpdatesMetadataStatus(t *testing.T) { | |
109 | + ws, _ := setupWorkspace(t, "interop test") | |
110 | + ws.Branch("exp", "experiment") | |
111 | + ws.Commit("exp work", nil, nil) | |
112 | + ws.Merge("exp", nil, mainBranch) | |
113 | + | |
114 | + raw, err := os.ReadFile(ws.metaPath("exp")) | |
115 | + if err != nil { | |
116 | + t.Fatalf("read metadata.yaml: %v", err) | |
117 | + } | |
118 | + content := string(raw) | |
119 | + | |
120 | + if !strings.Contains(content, "status: merged") { | |
121 | + t.Error("merged branch metadata should have status: merged") | |
122 | + } | |
123 | + if !strings.Contains(content, "merged_into:") { | |
124 | + t.Error("merged branch metadata should have merged_into field") | |
125 | + } | |
126 | + if !strings.Contains(content, "merged_at:") { | |
127 | + t.Error("merged branch metadata should have merged_at field") | |
128 | + } | |
129 | +} | |
130 | + | |
131 | +func TestInterop_DirectoryLayout(t *testing.T) { | |
132 | + ws, _ := setupWorkspace(t, "interop test") | |
133 | + ws.Branch("alt", "alternative") | |
134 | + | |
135 | + // Verify exact directory layout | |
136 | + checks := []struct { | |
137 | + path string | |
138 | + isDir bool | |
139 | + }{ | |
140 | + {filepath.Join(ws.root, gccDir, "main.md"), false}, | |
141 | + {filepath.Join(ws.root, gccDir, "branches"), true}, | |
142 | + {filepath.Join(ws.root, gccDir, "branches", "main"), true}, | |
143 | + {filepath.Join(ws.root, gccDir, "branches", "main", "log.md"), false}, | |
144 | + {filepath.Join(ws.root, gccDir, "branches", "main", "commit.md"), false}, | |
145 | + {filepath.Join(ws.root, gccDir, "branches", "main", "metadata.yaml"), false}, | |
146 | + {filepath.Join(ws.root, gccDir, "branches", "alt"), true}, | |
147 | + {filepath.Join(ws.root, gccDir, "branches", "alt", "log.md"), false}, | |
148 | + {filepath.Join(ws.root, gccDir, "branches", "alt", "commit.md"), false}, | |
149 | + {filepath.Join(ws.root, gccDir, "branches", "alt", "metadata.yaml"), false}, | |
150 | + } | |
151 | + for _, c := range checks { | |
152 | + info, err := os.Stat(c.path) | |
153 | + if err != nil { | |
154 | + t.Errorf("expected %s to exist", c.path) | |
155 | + continue | |
156 | + } | |
157 | + if c.isDir && !info.IsDir() { | |
158 | + t.Errorf("expected %s to be a directory", c.path) | |
159 | + } | |
160 | + if !c.isDir && info.IsDir() { | |
161 | + t.Errorf("expected %s to be a file", c.path) | |
162 | + } | |
163 | + } | |
164 | +} | |
165 | + | |
166 | +// ------------------------------------------------------------------ // | |
167 | +// Round-trip: hand-craft workspace → Go reads it // | |
168 | +// ------------------------------------------------------------------ // | |
169 | + | |
170 | +func TestInterop_ReadHandCraftedWorkspace(t *testing.T) { | |
171 | + // Simulate what another language (e.g., Python, Zig, Rust) would write | |
172 | + dir := t.TempDir() | |
173 | + gccRoot := filepath.Join(dir, ".GCC") | |
174 | + branchDir := filepath.Join(gccRoot, "branches", "main") | |
175 | + os.MkdirAll(branchDir, 0o755) | |
176 | + | |
177 | + // main.md — roadmap | |
178 | + os.WriteFile(filepath.Join(gccRoot, "main.md"), | |
179 | + []byte("# Roadmap\nBuild a cross-language test system\n"), 0o644) | |
180 | + | |
181 | + // metadata.yaml — hand-crafted | |
182 | + os.WriteFile(filepath.Join(branchDir, "metadata.yaml"), | |
183 | + []byte("name: main\npurpose: primary development branch\ncreated_from: \"\"\ncreated_at: \"2025-06-01T00:00:00Z\"\nstatus: active\n"), 0o644) | |
184 | + | |
185 | + // log.md — two OTA steps with canonical format | |
186 | + os.WriteFile(filepath.Join(branchDir, "log.md"), | |
187 | + []byte("# OTA Log — main\n\n"+ | |
188 | + "### Step 1 — 2025-06-01T00:00:01Z\n"+ | |
189 | + "**Observation:** Saw the input data\n\n"+ | |
190 | + "**Thought:** Need to process it\n\n"+ | |
191 | + "**Action:** Called the API\n\n"+ | |
192 | + "---\n"+ | |
193 | + "### Step 2 — 2025-06-01T00:00:02Z\n"+ | |
194 | + "**Observation:** Got response\n\n"+ | |
195 | + "**Thought:** Looks correct\n\n"+ | |
196 | + "**Action:** Saved results\n\n"+ | |
197 | + "---\n"), | |
198 | + 0o644) | |
199 | + | |
200 | + // commit.md — two commits with canonical format | |
201 | + os.WriteFile(filepath.Join(branchDir, "commit.md"), | |
202 | + []byte("## Commit `abc12345`\n"+ | |
203 | + "**Timestamp:** 2025-06-01T00:00:10Z\n\n"+ | |
204 | + "**Branch Purpose:** primary development branch\n\n"+ | |
205 | + "**Previous Progress Summary:** Initial state — no prior commits.\n\n"+ | |
206 | + "**This Commit's Contribution:** Completed data ingestion\n\n"+ | |
207 | + "---\n"+ | |
208 | + "## Commit `def67890`\n"+ | |
209 | + "**Timestamp:** 2025-06-01T00:00:20Z\n\n"+ | |
210 | + "**Branch Purpose:** primary development branch\n\n"+ | |
211 | + "**Previous Progress Summary:** Completed data ingestion\n\n"+ | |
212 | + "**This Commit's Contribution:** Added validation layer\n\n"+ | |
213 | + "---\n"), | |
214 | + 0o644) | |
215 | + | |
216 | + // Now read it with Go's workspace | |
217 | + ws := New(dir) | |
218 | + | |
219 | + // Context should parse everything | |
220 | + ctx, err := ws.Context(nil, 5) | |
221 | + if err != nil { | |
222 | + t.Fatalf("Context on hand-crafted workspace: %v", err) | |
223 | + } | |
224 | + | |
225 | + if !strings.Contains(ctx.MainRoadmap, "cross-language test system") { | |
226 | + t.Error("should read roadmap from hand-crafted main.md") | |
227 | + } | |
228 | + | |
229 | + if len(ctx.Commits) != 2 { | |
230 | + t.Fatalf("expected 2 commits, got %d", len(ctx.Commits)) | |
231 | + } | |
232 | + | |
233 | + if ctx.Commits[0].CommitID != "abc12345" { | |
234 | + t.Errorf("first commit ID should be abc12345, got %s", ctx.Commits[0].CommitID) | |
235 | + } | |
236 | + if ctx.Commits[1].ThisCommitContribution != "Added validation layer" { | |
237 | + t.Errorf("second commit contribution wrong: %s", ctx.Commits[1].ThisCommitContribution) | |
238 | + } | |
239 | + | |
240 | + // K=1 should return only the last commit | |
241 | + ctx1, err := ws.Context(nil, 1) | |
242 | + if err != nil { | |
243 | + t.Fatalf("Context K=1: %v", err) | |
244 | + } | |
245 | + if len(ctx1.Commits) != 1 { | |
246 | + t.Fatalf("K=1 should return 1 commit, got %d", len(ctx1.Commits)) | |
247 | + } | |
248 | + if ctx1.Commits[0].CommitID != "def67890" { | |
249 | + t.Error("K=1 should return the most recent commit") | |
250 | + } | |
251 | +} | |
252 | + | |
253 | +func TestInterop_SanitizationRoundTrip(t *testing.T) { | |
254 | + ws, _ := setupWorkspace(t, "interop sanitization test") | |
255 | + | |
256 | + // Content that contains the separator sequence | |
257 | + dangerous := "Here is a separator:\n---\nThis should not break parsing" | |
258 | + ws.LogOTA(dangerous, "safe thought", "safe action") | |
259 | + ws.Commit(dangerous, nil, nil) | |
260 | + | |
261 | + // Read back via Context | |
262 | + ctx, err := ws.Context(nil, 1) | |
263 | + if err != nil { | |
264 | + t.Fatalf("Context after sanitized write: %v", err) | |
265 | + } | |
266 | + | |
267 | + // The commit contribution should contain the original text (desanitized) | |
268 | + if ctx.Commits[0].ThisCommitContribution != dangerous { | |
269 | + t.Errorf("round-trip sanitization failed.\nExpected: %q\nGot: %q", | |
270 | + dangerous, ctx.Commits[0].ThisCommitContribution) | |
271 | + } | |
272 | + | |
273 | + // Raw file should contain the escaped form, not the raw separator | |
274 | + rawLog := ws.read(ws.logPath(mainBranch)) | |
275 | + if strings.Contains(rawLog, "\n---\nThis should not break") { | |
276 | + t.Error("raw log.md should contain escaped separator, not raw") | |
277 | + } | |
278 | + if !strings.Contains(rawLog, "\\---") { | |
279 | + t.Error("raw log.md should contain escaped '\\---'") | |
280 | + } | |
281 | +} | |
282 | + | |
283 | +func TestInterop_BranchLogHasHeader(t *testing.T) { | |
284 | + ws, _ := setupWorkspace(t, "interop test") | |
285 | + ws.Branch("feat", "new feature") | |
286 | + | |
287 | + raw := ws.read(ws.logPath("feat")) | |
288 | + // All languages write "# OTA Log — <branch>\n" as the first line | |
289 | + if !strings.HasPrefix(raw, "# OTA Log") { | |
290 | + t.Errorf("log.md should start with '# OTA Log', got: %q", | |
291 | + raw[:min(40, len(raw))]) | |
292 | + } | |
293 | + if !strings.Contains(raw, "feat") { | |
294 | + t.Error("log.md header should contain branch name") | |
295 | + } | |
296 | +} | |
297 | + | |
298 | +func min(a, b int) int { | |
299 | + if a < b { | |
300 | + return a | |
301 | + } | |
302 | + return b | |
303 | +} | |
0 | 304 | |
▼GO/cortexa/models.go
+34-4
| @@ -1,4 +1,4 @@ | ||
1 | -// Package cortexa implements contexa — Git-inspired context management | |
1 | +// Package cortexa implements cortexa — Git-inspired context management | |
2 | 2 | // for LLM agents. COMMIT, BRANCH, MERGE, and CONTEXT over versioned memory. |
3 | 3 | // |
4 | 4 | // Paper: "Git Context Controller: Manage the Context of LLM-based Agents like Git" |
| @@ -13,6 +13,36 @@ import ( | ||
13 | 13 | "strings" |
14 | 14 | ) |
15 | 15 | |
16 | +// Sanitize escapes separator sequences in user-provided content. | |
17 | +func Sanitize(text string) string { | |
18 | + return strings.Replace(text, "\n---\n", "\n\\---\n", -1) | |
19 | +} | |
20 | + | |
21 | +// Desanitize reverses the escaping applied by Sanitize. | |
22 | +func Desanitize(text string) string { | |
23 | + return strings.Replace(text, "\n\\---\n", "\n---\n", -1) | |
24 | +} | |
25 | + | |
26 | +// SplitBlocks splits markdown text on the "---\n" separator while | |
27 | +// respecting escaped separators ("\---\n" produced by Sanitize). | |
28 | +// After splitting, escaped separators are left in place; callers | |
29 | +// should use Desanitize on individual field values. | |
30 | +func SplitBlocks(text string) []string { | |
31 | + raw := strings.Split(text, "---\n") | |
32 | + var blocks []string | |
33 | + for i := 0; i < len(raw); i++ { | |
34 | + block := raw[i] | |
35 | + // If block ends with '\', the "---\n" was actually an escaped | |
36 | + // separator — rejoin with the next fragment. | |
37 | + for strings.HasSuffix(block, "\\") && i+1 < len(raw) { | |
38 | + i++ | |
39 | + block = block + "---\n" + raw[i] | |
40 | + } | |
41 | + blocks = append(blocks, block) | |
42 | + } | |
43 | + return blocks | |
44 | +} | |
45 | + | |
16 | 46 | // OTARecord represents a single Observation–Thought–Action cycle logged to log.md. |
17 | 47 | type OTARecord struct { |
18 | 48 | Step int `yaml:"step"` |
| @@ -26,7 +56,7 @@ type OTARecord struct { | ||
26 | 56 | func (r OTARecord) ToMarkdown() string { |
27 | 57 | return fmt.Sprintf( |
28 | 58 | "### Step %d — %s\n**Observation:** %s\n\n**Thought:** %s\n\n**Action:** %s\n\n---\n", |
29 | - r.Step, r.Timestamp, r.Observation, r.Thought, r.Action, | |
59 | + r.Step, r.Timestamp, Sanitize(r.Observation), Sanitize(r.Thought), Sanitize(r.Action), | |
30 | 60 | ) |
31 | 61 | } |
32 | 62 | |
| @@ -47,8 +77,8 @@ func (c CommitRecord) ToMarkdown() string { | ||
47 | 77 | "## Commit `%s`\n**Timestamp:** %s\n\n**Branch Purpose:** %s\n\n"+ |
48 | 78 | "**Previous Progress Summary:** %s\n\n"+ |
49 | 79 | "**This Commit's Contribution:** %s\n\n---\n", |
50 | - c.CommitID, c.Timestamp, c.BranchPurpose, | |
51 | - c.PreviousProgressSummary, c.ThisCommitContribution, | |
80 | + c.CommitID, c.Timestamp, Sanitize(c.BranchPurpose), | |
81 | + Sanitize(c.PreviousProgressSummary), Sanitize(c.ThisCommitContribution), | |
52 | 82 | ) |
53 | 83 | } |
54 | 84 | |
55 | 85 | |
▼GO/cortexa/workspace.go
+321-157
| @@ -7,6 +7,7 @@ import ( | ||
7 | 7 | "os" |
8 | 8 | "path/filepath" |
9 | 9 | "strings" |
10 | + "syscall" | |
10 | 11 | "time" |
11 | 12 | |
12 | 13 | "gopkg.in/yaml.v3" |
| @@ -27,6 +28,28 @@ func shortID() string { | ||
27 | 28 | return hex.EncodeToString(b) |
28 | 29 | } |
29 | 30 | |
31 | +// validateNotEmpty returns an error if value is empty or whitespace-only. | |
32 | +func validateNotEmpty(value, field string) error { | |
33 | + if strings.TrimSpace(value) == "" { | |
34 | + return fmt.Errorf("%s must not be empty", field) | |
35 | + } | |
36 | + return nil | |
37 | +} | |
38 | + | |
39 | +// validateBranchName returns an error if name is not a valid branch identifier. | |
40 | +func validateBranchName(name string) error { | |
41 | + if err := validateNotEmpty(name, "branch name"); err != nil { | |
42 | + return err | |
43 | + } | |
44 | + if strings.Contains(name, "/") || strings.Contains(name, "\\") { | |
45 | + return fmt.Errorf("branch name must not contain path separators: %q", name) | |
46 | + } | |
47 | + if name == "." || name == ".." { | |
48 | + return fmt.Errorf("branch name must not be '.' or '..': %q", name) | |
49 | + } | |
50 | + return nil | |
51 | +} | |
52 | + | |
30 | 53 | // Workspace manages the .GCC/ directory for one agent project. |
31 | 54 | // |
32 | 55 | // Implements the four GCC commands (arXiv:2508.00031v2): |
| @@ -82,6 +105,43 @@ func (w *Workspace) mainMd() string { | ||
82 | 105 | return filepath.Join(w.gccPath, "main.md") |
83 | 106 | } |
84 | 107 | |
108 | +// ------------------------------------------------------------------ // | |
109 | +// File locking // | |
110 | +// ------------------------------------------------------------------ // | |
111 | + | |
112 | +// withLock acquires an exclusive file lock on .GCC/.lock, runs fn, | |
113 | +// then releases the lock. This serialises concurrent access to the | |
114 | +// workspace from multiple processes/goroutines. | |
115 | +func (w *Workspace) withLock(fn func() error) error { | |
116 | + lockPath := filepath.Join(w.gccPath, ".lock") | |
117 | + if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { | |
118 | + return err | |
119 | + } | |
120 | + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o644) | |
121 | + if err != nil { | |
122 | + return err | |
123 | + } | |
124 | + defer f.Close() | |
125 | + | |
126 | + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { | |
127 | + return fmt.Errorf("failed to acquire lock: %w", err) | |
128 | + } | |
129 | + defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN) | |
130 | + | |
131 | + return fn() | |
132 | +} | |
133 | + | |
134 | +// withLockReturn is like withLock but returns a value along with an error. | |
135 | +func withLockReturn[T any](w *Workspace, fn func() (T, error)) (T, error) { | |
136 | + var result T | |
137 | + err := w.withLock(func() error { | |
138 | + var innerErr error | |
139 | + result, innerErr = fn() | |
140 | + return innerErr | |
141 | + }) | |
142 | + return result, err | |
143 | +} | |
144 | + | |
85 | 145 | // ------------------------------------------------------------------ // |
86 | 146 | // I/O helpers // |
87 | 147 | // ------------------------------------------------------------------ // |
| @@ -114,35 +174,63 @@ func (w *Workspace) appendFile(path, content string) error { | ||
114 | 174 | func (w *Workspace) parseCommits(branch string) []CommitRecord { |
115 | 175 | text := w.read(w.commitPath(branch)) |
116 | 176 | var records []CommitRecord |
117 | - for _, block := range strings.Split(text, "---\n") { | |
177 | + for _, block := range SplitBlocks(text) { | |
118 | 178 | block = strings.TrimSpace(block) |
119 | 179 | if block == "" { |
120 | 180 | continue |
121 | 181 | } |
122 | 182 | var ( |
123 | - commitID, branchPurpose, prevSummary, contribution, ts string | |
183 | + commitID string | |
184 | + ts string | |
124 | 185 | ) |
186 | + // Accumulate multi-line field values keyed by field name. | |
187 | + fields := map[string]*strings.Builder{} | |
188 | + var current *strings.Builder | |
189 | + | |
125 | 190 | for _, line := range strings.Split(block, "\n") { |
126 | 191 | switch { |
127 | 192 | case strings.HasPrefix(line, "## Commit `"): |
128 | 193 | commitID = strings.TrimSuffix(strings.TrimPrefix(line, "## Commit `"), "`") |
194 | + current = nil | |
129 | 195 | case strings.HasPrefix(line, "**Timestamp:**"): |
130 | 196 | ts = strings.TrimSpace(strings.TrimPrefix(line, "**Timestamp:**")) |
197 | + current = nil | |
131 | 198 | case strings.HasPrefix(line, "**Branch Purpose:**"): |
132 | - branchPurpose = strings.TrimSpace(strings.TrimPrefix(line, "**Branch Purpose:**")) | |
199 | + b := &strings.Builder{} | |
200 | + b.WriteString(strings.TrimSpace(strings.TrimPrefix(line, "**Branch Purpose:**"))) | |
201 | + fields["branch_purpose"] = b | |
202 | + current = b | |
133 | 203 | case strings.HasPrefix(line, "**Previous Progress Summary:**"): |
134 | - prevSummary = strings.TrimSpace(strings.TrimPrefix(line, "**Previous Progress Summary:**")) | |
204 | + b := &strings.Builder{} | |
205 | + b.WriteString(strings.TrimSpace(strings.TrimPrefix(line, "**Previous Progress Summary:**"))) | |
206 | + fields["prev_summary"] = b | |
207 | + current = b | |
135 | 208 | case strings.HasPrefix(line, "**This Commit's Contribution:**"): |
136 | - contribution = strings.TrimSpace(strings.TrimPrefix(line, "**This Commit's Contribution:**")) | |
209 | + b := &strings.Builder{} | |
210 | + b.WriteString(strings.TrimSpace(strings.TrimPrefix(line, "**This Commit's Contribution:**"))) | |
211 | + fields["contribution"] = b | |
212 | + current = b | |
213 | + default: | |
214 | + // Continuation line for the current field | |
215 | + if current != nil && line != "" { | |
216 | + current.WriteString("\n") | |
217 | + current.WriteString(line) | |
218 | + } | |
137 | 219 | } |
138 | 220 | } |
139 | 221 | if commitID != "" { |
222 | + getField := func(key string) string { | |
223 | + if b, ok := fields[key]; ok { | |
224 | + return Desanitize(strings.TrimSpace(b.String())) | |
225 | + } | |
226 | + return "" | |
227 | + } | |
140 | 228 | records = append(records, CommitRecord{ |
141 | 229 | CommitID: commitID, |
142 | 230 | BranchName: branch, |
143 | - BranchPurpose: branchPurpose, | |
144 | - PreviousProgressSummary: prevSummary, | |
145 | - ThisCommitContribution: contribution, | |
231 | + BranchPurpose: getField("branch_purpose"), | |
232 | + PreviousProgressSummary: getField("prev_summary"), | |
233 | + ThisCommitContribution: getField("contribution"), | |
146 | 234 | Timestamp: ts, |
147 | 235 | }) |
148 | 236 | } |
| @@ -153,13 +241,16 @@ func (w *Workspace) parseCommits(branch string) []CommitRecord { | ||
153 | 241 | func (w *Workspace) parseOTA(branch string) []OTARecord { |
154 | 242 | text := w.read(w.logPath(branch)) |
155 | 243 | var records []OTARecord |
156 | - for _, block := range strings.Split(text, "---\n") { | |
244 | + for _, block := range SplitBlocks(text) { | |
157 | 245 | block = strings.TrimSpace(block) |
158 | 246 | if block == "" { |
159 | 247 | continue |
160 | 248 | } |
161 | 249 | var step int |
162 | - var ts, obs, thought, action string | |
250 | + var ts string | |
251 | + fields := map[string]*strings.Builder{} | |
252 | + var current *strings.Builder | |
253 | + | |
163 | 254 | for _, line := range strings.Split(block, "\n") { |
164 | 255 | switch { |
165 | 256 | case strings.HasPrefix(line, "### Step "): |
| @@ -168,21 +259,44 @@ func (w *Workspace) parseOTA(branch string) []OTARecord { | ||
168 | 259 | if len(parts) > 1 { |
169 | 260 | ts = strings.TrimSpace(parts[1]) |
170 | 261 | } |
262 | + current = nil | |
171 | 263 | case strings.HasPrefix(line, "**Observation:**"): |
172 | - obs = strings.TrimSpace(strings.TrimPrefix(line, "**Observation:**")) | |
264 | + b := &strings.Builder{} | |
265 | + b.WriteString(strings.TrimSpace(strings.TrimPrefix(line, "**Observation:**"))) | |
266 | + fields["obs"] = b | |
267 | + current = b | |
173 | 268 | case strings.HasPrefix(line, "**Thought:**"): |
174 | - thought = strings.TrimSpace(strings.TrimPrefix(line, "**Thought:**")) | |
269 | + b := &strings.Builder{} | |
270 | + b.WriteString(strings.TrimSpace(strings.TrimPrefix(line, "**Thought:**"))) | |
271 | + fields["thought"] = b | |
272 | + current = b | |
175 | 273 | case strings.HasPrefix(line, "**Action:**"): |
176 | - action = strings.TrimSpace(strings.TrimPrefix(line, "**Action:**")) | |
274 | + b := &strings.Builder{} | |
275 | + b.WriteString(strings.TrimSpace(strings.TrimPrefix(line, "**Action:**"))) | |
276 | + fields["action"] = b | |
277 | + current = b | |
278 | + default: | |
279 | + if current != nil && line != "" { | |
280 | + current.WriteString("\n") | |
281 | + current.WriteString(line) | |
282 | + } | |
283 | + } | |
284 | + } | |
285 | + getField := func(key string) string { | |
286 | + if b, ok := fields[key]; ok { | |
287 | + return Desanitize(strings.TrimSpace(b.String())) | |
177 | 288 | } |
289 | + return "" | |
178 | 290 | } |
291 | + obs := getField("obs") | |
292 | + thought := getField("thought") | |
179 | 293 | if obs != "" || thought != "" { |
180 | 294 | records = append(records, OTARecord{ |
181 | 295 | Step: step, |
182 | 296 | Timestamp: ts, |
183 | 297 | Observation: obs, |
184 | 298 | Thought: thought, |
185 | - Action: action, | |
299 | + Action: getField("action"), | |
186 | 300 | }) |
187 | 301 | } |
188 | 302 | } |
| @@ -214,34 +328,36 @@ func (w *Workspace) Init(projectRoadmap string) error { | ||
214 | 328 | return err |
215 | 329 | } |
216 | 330 | |
217 | - roadmap := fmt.Sprintf("# Project Roadmap\n\n**Initialized:** %s\n\n%s\n", nowISO(), projectRoadmap) | |
218 | - if err := w.write(w.mainMd(), roadmap); err != nil { | |
219 | - return err | |
220 | - } | |
221 | - if err := w.write(w.logPath(mainBranch), fmt.Sprintf("# OTA Log — branch `%s`\n\n", mainBranch)); err != nil { | |
222 | - return err | |
223 | - } | |
224 | - if err := w.write(w.commitPath(mainBranch), fmt.Sprintf("# Commit History — branch `%s`\n\n", mainBranch)); err != nil { | |
225 | - return err | |
226 | - } | |
331 | + return w.withLock(func() error { | |
332 | + roadmap := fmt.Sprintf("# Project Roadmap\n\n**Initialized:** %s\n\n%s\n", nowISO(), projectRoadmap) | |
333 | + if err := w.write(w.mainMd(), roadmap); err != nil { | |
334 | + return err | |
335 | + } | |
336 | + if err := w.write(w.logPath(mainBranch), fmt.Sprintf("# OTA Log — branch `%s`\n\n", mainBranch)); err != nil { | |
337 | + return err | |
338 | + } | |
339 | + if err := w.write(w.commitPath(mainBranch), fmt.Sprintf("# Commit History — branch `%s`\n\n", mainBranch)); err != nil { | |
340 | + return err | |
341 | + } | |
227 | 342 | |
228 | - meta := BranchMetadata{ | |
229 | - Name: mainBranch, | |
230 | - Purpose: "Primary reasoning trajectory", | |
231 | - CreatedFrom: "", | |
232 | - CreatedAt: nowISO(), | |
233 | - Status: "active", | |
234 | - } | |
235 | - data, err := yaml.Marshal(meta) | |
236 | - if err != nil { | |
237 | - return err | |
238 | - } | |
239 | - if err := w.write(w.metaPath(mainBranch), string(data)); err != nil { | |
240 | - return err | |
241 | - } | |
343 | + meta := BranchMetadata{ | |
344 | + Name: mainBranch, | |
345 | + Purpose: "Primary reasoning trajectory", | |
346 | + CreatedFrom: "", | |
347 | + CreatedAt: nowISO(), | |
348 | + Status: "active", | |
349 | + } | |
350 | + data, err := yaml.Marshal(meta) | |
351 | + if err != nil { | |
352 | + return err | |
353 | + } | |
354 | + if err := w.write(w.metaPath(mainBranch), string(data)); err != nil { | |
355 | + return err | |
356 | + } | |
242 | 357 | |
243 | - w.currentBranch = mainBranch | |
244 | - return nil | |
358 | + w.currentBranch = mainBranch | |
359 | + return nil | |
360 | + }) | |
245 | 361 | } |
246 | 362 | |
247 | 363 | // Load opens an existing GCC workspace. |
| @@ -259,97 +375,121 @@ func (w *Workspace) Load() error { | ||
259 | 375 | |
260 | 376 | // LogOTA appends an Observation–Thought–Action step to the current branch's log.md. |
261 | 377 | func (w *Workspace) LogOTA(observation, thought, action string) (OTARecord, error) { |
262 | - existing := w.parseOTA(w.currentBranch) | |
263 | - record := OTARecord{ | |
264 | - Step: len(existing) + 1, | |
265 | - Timestamp: nowISO(), | |
266 | - Observation: observation, | |
267 | - Thought: thought, | |
268 | - Action: action, | |
269 | - } | |
270 | - if err := w.appendFile(w.logPath(w.currentBranch), record.ToMarkdown()); err != nil { | |
271 | - return OTARecord{}, err | |
272 | - } | |
273 | - return record, nil | |
378 | + if strings.TrimSpace(observation) == "" && strings.TrimSpace(thought) == "" && strings.TrimSpace(action) == "" { | |
379 | + return OTARecord{}, fmt.Errorf("at least one of observation, thought, or action must be non-empty") | |
380 | + } | |
381 | + return withLockReturn(w, func() (OTARecord, error) { | |
382 | + existing := w.parseOTA(w.currentBranch) | |
383 | + record := OTARecord{ | |
384 | + Step: len(existing) + 1, | |
385 | + Timestamp: nowISO(), | |
386 | + Observation: observation, | |
387 | + Thought: thought, | |
388 | + Action: action, | |
389 | + } | |
390 | + if err := w.appendFile(w.logPath(w.currentBranch), record.ToMarkdown()); err != nil { | |
391 | + return OTARecord{}, err | |
392 | + } | |
393 | + return record, nil | |
394 | + }) | |
274 | 395 | } |
275 | 396 | |
276 | 397 | // Commit is the COMMIT command (paper §3.2). |
277 | 398 | // Checkpoints milestone with: Branch Purpose, Previous Progress Summary, |
278 | 399 | // This Commit's Contribution. |
279 | 400 | func (w *Workspace) Commit(contribution string, previousSummary *string, updateRoadmap *string) (CommitRecord, error) { |
280 | - meta, _ := w.parseMeta(w.currentBranch) | |
281 | - branchPurpose := "" | |
282 | - if meta != nil { | |
283 | - branchPurpose = meta.Purpose | |
401 | + if err := validateNotEmpty(contribution, "contribution"); err != nil { | |
402 | + return CommitRecord{}, err | |
284 | 403 | } |
404 | + return withLockReturn(w, func() (CommitRecord, error) { | |
405 | + meta, _ := w.parseMeta(w.currentBranch) | |
406 | + branchPurpose := "" | |
407 | + if meta != nil { | |
408 | + branchPurpose = meta.Purpose | |
409 | + } | |
285 | 410 | |
286 | - prevSummary := "Initial state — no prior commits." | |
287 | - if previousSummary != nil { | |
288 | - prevSummary = *previousSummary | |
289 | - } else { | |
290 | - commits := w.parseCommits(w.currentBranch) | |
291 | - if len(commits) > 0 { | |
292 | - prevSummary = commits[len(commits)-1].ThisCommitContribution | |
411 | + prevSummary := "Initial state — no prior commits." | |
412 | + if previousSummary != nil { | |
413 | + prevSummary = *previousSummary | |
414 | + } else { | |
415 | + commits := w.parseCommits(w.currentBranch) | |
416 | + if len(commits) > 0 { | |
417 | + prevSummary = commits[len(commits)-1].ThisCommitContribution | |
418 | + } | |
293 | 419 | } |
294 | - } | |
295 | 420 | |
296 | - record := CommitRecord{ | |
297 | - CommitID: shortID(), | |
298 | - BranchName: w.currentBranch, | |
299 | - BranchPurpose: branchPurpose, | |
300 | - PreviousProgressSummary: prevSummary, | |
301 | - ThisCommitContribution: contribution, | |
302 | - Timestamp: nowISO(), | |
303 | - } | |
421 | + record := CommitRecord{ | |
422 | + CommitID: shortID(), | |
423 | + BranchName: w.currentBranch, | |
424 | + BranchPurpose: branchPurpose, | |
425 | + PreviousProgressSummary: prevSummary, | |
426 | + ThisCommitContribution: contribution, | |
427 | + Timestamp: nowISO(), | |
428 | + } | |
304 | 429 | |
305 | - if err := w.appendFile(w.commitPath(w.currentBranch), record.ToMarkdown()); err != nil { | |
306 | - return CommitRecord{}, err | |
307 | - } | |
308 | - if updateRoadmap != nil { | |
309 | - update := fmt.Sprintf("\n## Update (%s)\n%s\n", record.Timestamp, *updateRoadmap) | |
310 | - if err := w.appendFile(w.mainMd(), update); err != nil { | |
430 | + if err := w.appendFile(w.commitPath(w.currentBranch), record.ToMarkdown()); err != nil { | |
311 | 431 | return CommitRecord{}, err |
312 | 432 | } |
313 | - } | |
314 | - return record, nil | |
433 | + if updateRoadmap != nil { | |
434 | + update := fmt.Sprintf("\n## Update (%s)\n%s\n", record.Timestamp, *updateRoadmap) | |
435 | + if err := w.appendFile(w.mainMd(), update); err != nil { | |
436 | + return CommitRecord{}, err | |
437 | + } | |
438 | + } | |
439 | + return record, nil | |
440 | + }) | |
315 | 441 | } |
316 | 442 | |
317 | 443 | // Branch is the BRANCH command (paper §3.3). |
318 | 444 | // Creates isolated workspace: B_t^(name) = BRANCH(M_{t-1}). |
319 | 445 | func (w *Workspace) Branch(name, purpose string) error { |
320 | - if _, err := os.Stat(w.branchDir(name)); err == nil { | |
321 | - return fmt.Errorf("branch '%s' already exists", name) | |
322 | - } | |
323 | - if err := os.MkdirAll(w.branchDir(name), 0o755); err != nil { | |
324 | - return err | |
325 | - } | |
326 | - if err := w.write(w.logPath(name), fmt.Sprintf("# OTA Log — branch `%s`\n\n", name)); err != nil { | |
446 | + if err := validateBranchName(name); err != nil { | |
327 | 447 | return err |
328 | 448 | } |
329 | - if err := w.write(w.commitPath(name), fmt.Sprintf("# Commit History — branch `%s`\n\n", name)); err != nil { | |
449 | + if err := validateNotEmpty(purpose, "branch purpose"); err != nil { | |
330 | 450 | return err |
331 | 451 | } |
332 | - meta := BranchMetadata{ | |
333 | - Name: name, | |
334 | - Purpose: purpose, | |
335 | - CreatedFrom: w.currentBranch, | |
336 | - CreatedAt: nowISO(), | |
337 | - Status: "active", | |
338 | - } | |
339 | - data, err := yaml.Marshal(meta) | |
340 | - if err != nil { | |
341 | - return err | |
452 | + if _, err := os.Stat(w.branchDir(name)); err == nil { | |
453 | + return fmt.Errorf("branch '%s' already exists", name) | |
342 | 454 | } |
343 | - if err := w.write(w.metaPath(name), string(data)); err != nil { | |
455 | + if err := os.MkdirAll(w.branchDir(name), 0o755); err != nil { | |
344 | 456 | return err |
345 | 457 | } |
346 | - w.currentBranch = name | |
347 | - return nil | |
458 | + return w.withLock(func() error { | |
459 | + if err := w.write(w.logPath(name), fmt.Sprintf("# OTA Log — branch `%s`\n\n", name)); err != nil { | |
460 | + return err | |
461 | + } | |
462 | + if err := w.write(w.commitPath(name), fmt.Sprintf("# Commit History — branch `%s`\n\n", name)); err != nil { | |
463 | + return err | |
464 | + } | |
465 | + meta := BranchMetadata{ | |
466 | + Name: name, | |
467 | + Purpose: purpose, | |
468 | + CreatedFrom: w.currentBranch, | |
469 | + CreatedAt: nowISO(), | |
470 | + Status: "active", | |
471 | + } | |
472 | + data, err := yaml.Marshal(meta) | |
473 | + if err != nil { | |
474 | + return err | |
475 | + } | |
476 | + if err := w.write(w.metaPath(name), string(data)); err != nil { | |
477 | + return err | |
478 | + } | |
479 | + w.currentBranch = name | |
480 | + return nil | |
481 | + }) | |
348 | 482 | } |
349 | 483 | |
350 | 484 | // Merge is the MERGE command (paper §3.4). |
351 | 485 | // Integrates a completed branch into target, merging summaries and OTA traces. |
352 | 486 | func (w *Workspace) Merge(branchName string, summary *string, target string) (CommitRecord, error) { |
487 | + if err := validateBranchName(branchName); err != nil { | |
488 | + return CommitRecord{}, err | |
489 | + } | |
490 | + if err := validateBranchName(target); err != nil { | |
491 | + return CommitRecord{}, err | |
492 | + } | |
353 | 493 | if _, err := os.Stat(w.branchDir(branchName)); os.IsNotExist(err) { |
354 | 494 | return CommitRecord{}, fmt.Errorf("branch '%s' not found", branchName) |
355 | 495 | } |
| @@ -357,35 +497,47 @@ func (w *Workspace) Merge(branchName string, summary *string, target string) (Co | ||
357 | 497 | return CommitRecord{}, fmt.Errorf("target branch '%s' not found", target) |
358 | 498 | } |
359 | 499 | |
360 | - branchCommits := w.parseCommits(branchName) | |
361 | - branchOTA := w.parseOTA(branchName) | |
362 | - meta, _ := w.parseMeta(branchName) | |
363 | - | |
364 | 500 | var mergeSummary string |
365 | - if summary != nil { | |
366 | - mergeSummary = *summary | |
367 | - } else { | |
368 | - contribs := make([]string, len(branchCommits)) | |
369 | - for i, c := range branchCommits { | |
370 | - contribs[i] = c.ThisCommitContribution | |
501 | + var meta *BranchMetadata | |
502 | + | |
503 | + err := w.withLock(func() error { | |
504 | + branchCommits := w.parseCommits(branchName) | |
505 | + branchOTA := w.parseOTA(branchName) | |
506 | + var parseErr error | |
507 | + meta, parseErr = w.parseMeta(branchName) | |
508 | + _ = parseErr | |
509 | + | |
510 | + if summary != nil { | |
511 | + mergeSummary = *summary | |
512 | + } else { | |
513 | + contribs := make([]string, len(branchCommits)) | |
514 | + for i, c := range branchCommits { | |
515 | + contribs[i] = c.ThisCommitContribution | |
516 | + } | |
517 | + mergeSummary = fmt.Sprintf("Merged branch `%s` (%d commits). Contributions: %s", | |
518 | + branchName, len(branchCommits), strings.Join(contribs, " | ")) | |
371 | 519 | } |
372 | - mergeSummary = fmt.Sprintf("Merged branch `%s` (%d commits). Contributions: %s", | |
373 | - branchName, len(branchCommits), strings.Join(contribs, " | ")) | |
374 | - } | |
375 | 520 | |
376 | - if len(branchOTA) > 0 { | |
377 | - header := fmt.Sprintf("\n## Merged from `%s` (%s)\n\n", branchName, nowISO()) | |
378 | - if err := w.appendFile(w.logPath(target), header); err != nil { | |
379 | - return CommitRecord{}, err | |
380 | - } | |
381 | - for _, rec := range branchOTA { | |
382 | - if err := w.appendFile(w.logPath(target), rec.ToMarkdown()); err != nil { | |
383 | - return CommitRecord{}, err | |
521 | + if len(branchOTA) > 0 { | |
522 | + header := fmt.Sprintf("\n## Merged from `%s` (%s)\n\n", branchName, nowISO()) | |
523 | + if err := w.appendFile(w.logPath(target), header); err != nil { | |
524 | + return err | |
525 | + } | |
526 | + for _, rec := range branchOTA { | |
527 | + if err := w.appendFile(w.logPath(target), rec.ToMarkdown()); err != nil { | |
528 | + return err | |
529 | + } | |
384 | 530 | } |
385 | 531 | } |
532 | + | |
533 | + w.currentBranch = target | |
534 | + return nil | |
535 | + }) | |
536 | + if err != nil { | |
537 | + return CommitRecord{}, err | |
386 | 538 | } |
387 | 539 | |
388 | - w.currentBranch = target | |
540 | + // Commit acquires the lock internally | |
389 | 541 | metaPurpose := "" |
390 | 542 | if meta != nil { |
391 | 543 | metaPurpose = meta.Purpose |
| @@ -396,19 +548,23 @@ func (w *Workspace) Merge(branchName string, summary *string, target string) (Co | ||
396 | 548 | return CommitRecord{}, err |
397 | 549 | } |
398 | 550 | |
551 | + // Mark branch as merged | |
399 | 552 | if meta != nil { |
400 | - meta.Status = "merged" | |
401 | - merged := target | |
402 | - meta.MergedInto = &merged | |
403 | - mergedAt := nowISO() | |
404 | - meta.MergedAt = &mergedAt | |
405 | - data, err := yaml.Marshal(meta) | |
553 | + err = w.withLock(func() error { | |
554 | + meta.Status = "merged" | |
555 | + merged := target | |
556 | + meta.MergedInto = &merged | |
557 | + mergedAt := nowISO() | |
558 | + meta.MergedAt = &mergedAt | |
559 | + data, err := yaml.Marshal(meta) | |
560 | + if err != nil { | |
561 | + return err | |
562 | + } | |
563 | + return w.write(w.metaPath(branchName), string(data)) | |
564 | + }) | |
406 | 565 | if err != nil { |
407 | 566 | return CommitRecord{}, err |
408 | 567 | } |
409 | - if err := w.write(w.metaPath(branchName), string(data)); err != nil { | |
410 | - return CommitRecord{}, err | |
411 | - } | |
412 | 568 | } |
413 | 569 | |
414 | 570 | return mergeCommit, nil |
| @@ -417,32 +573,37 @@ func (w *Workspace) Merge(branchName string, summary *string, target string) (Co | ||
417 | 573 | // Context is the CONTEXT command (paper §3.5). |
418 | 574 | // Retrieves history at K-commit resolution. Paper default: K=1. |
419 | 575 | func (w *Workspace) Context(branch *string, k int) (ContextResult, error) { |
420 | - target := w.currentBranch | |
421 | - if branch != nil { | |
422 | - target = *branch | |
576 | + if k < 1 { | |
577 | + return ContextResult{}, fmt.Errorf("k must be >= 1, got %d", k) | |
423 | 578 | } |
424 | - if _, err := os.Stat(w.branchDir(target)); os.IsNotExist(err) { | |
425 | - return ContextResult{}, fmt.Errorf("branch '%s' not found", target) | |
426 | - } | |
427 | - | |
428 | - allCommits := w.parseCommits(target) | |
429 | - start := len(allCommits) - k | |
430 | - if start < 0 { | |
431 | - start = 0 | |
432 | - } | |
433 | - commits := allCommits[start:] | |
434 | - otaRecords := w.parseOTA(target) | |
435 | - mainRoadmap := w.read(w.mainMd()) | |
436 | - meta, _ := w.parseMeta(target) | |
437 | - | |
438 | - return ContextResult{ | |
439 | - BranchName: target, | |
440 | - K: k, | |
441 | - Commits: commits, | |
442 | - OTARecords: otaRecords, | |
443 | - MainRoadmap: mainRoadmap, | |
444 | - Metadata: meta, | |
445 | - }, nil | |
579 | + return withLockReturn(w, func() (ContextResult, error) { | |
580 | + target := w.currentBranch | |
581 | + if branch != nil { | |
582 | + target = *branch | |
583 | + } | |
584 | + if _, err := os.Stat(w.branchDir(target)); os.IsNotExist(err) { | |
585 | + return ContextResult{}, fmt.Errorf("branch '%s' not found", target) | |
586 | + } | |
587 | + | |
588 | + allCommits := w.parseCommits(target) | |
589 | + start := len(allCommits) - k | |
590 | + if start < 0 { | |
591 | + start = 0 | |
592 | + } | |
593 | + commits := allCommits[start:] | |
594 | + otaRecords := w.parseOTA(target) | |
595 | + mainRoadmap := w.read(w.mainMd()) | |
596 | + meta, _ := w.parseMeta(target) | |
597 | + | |
598 | + return ContextResult{ | |
599 | + BranchName: target, | |
600 | + K: k, | |
601 | + Commits: commits, | |
602 | + OTARecords: otaRecords, | |
603 | + MainRoadmap: mainRoadmap, | |
604 | + Metadata: meta, | |
605 | + }, nil | |
606 | + }) | |
446 | 607 | } |
447 | 608 | |
448 | 609 | // ------------------------------------------------------------------ // |
| @@ -456,6 +617,9 @@ func (w *Workspace) CurrentBranch() string { | ||
456 | 617 | |
457 | 618 | // SwitchBranch changes the active branch without creating a new one. |
458 | 619 | func (w *Workspace) SwitchBranch(name string) error { |
620 | + if err := validateBranchName(name); err != nil { | |
621 | + return err | |
622 | + } | |
459 | 623 | if _, err := os.Stat(w.branchDir(name)); os.IsNotExist(err) { |
460 | 624 | return fmt.Errorf("branch '%s' does not exist", name) |
461 | 625 | } |
462 | 626 | |
▼GO/go.mod
+1-1
| @@ -1,4 +1,4 @@ | ||
1 | -module github.com/swadhinbiswas/contexa/GO | |
1 | +module github.com/swadhinbiswas/Cortexa/GO | |
2 | 2 | |
3 | 3 | go 1.21 |
4 | 4 | |
5 | 5 | |