Commit a869da2

Eric Bower  ·  2026-08-15 12:44:23 -0400 EDT
parent bfb4fa8
feat: --in-place and --summary-file flags
2 files changed,  +299, -6
+143, -6
......@@ -32,6 +32,13 @@ var tmplFS embed.FS
3232 type WorkspaceFactory func(cfg *Cfg, logger *slog.Logger, source string) Workspace
3333
3434 func defaultWorkspaceFactory(cfg *Cfg, logger *slog.Logger, source string) Workspace {
35+ if cfg != nil && cfg.InPlace {
36+ return &WorkspaceInPlace{
37+ Cfg: cfg,
38+ Logger: logger,
39+ Source: source,
40+ }
41+ }
3542 if strings.HasSuffix(source, ".tar") {
3643 return &WorkspaceTar{
3744 Cfg: cfg,
......@@ -73,6 +80,8 @@ type Cfg struct {
7380 IncludeRunning bool // emit running status updates in addition to terminal
7481 HumanOutput bool // human-readable output instead of JSONL / slog
7582 Wait bool // block until job completes, print history and summary
83+ InPlace bool // execute workspace in-place without rsyncing to /tmp
84+ SummaryFile string // write markdown summary to this file path
7685 EnvVars envList // custom environment variables passed via -e / -env
7786 SessionPrefix string // session prefix filter for monitor (default: "ci.")
7887 }
......@@ -119,9 +128,13 @@ func NewCfg() (*Cfg, string, bool) {
119128 var includeRunning bool
120129 var human bool
121130 var wait bool
131+ var inPlace bool
132+ var summaryFile string
122133 flag.BoolVar(&includeRunning, "include-running", false, "emit running status updates in addition to terminal (default: terminal only)")
123134 flag.BoolVar(&human, "human", false, "human-readable output (default: JSONL / slog)")
124135 flag.BoolVar(&wait, "wait", false, "block until job completes, printing session history and summary")
136+ flag.BoolVar(&inPlace, "in-place", false, "execute workspace in-place without rsyncing to /tmp")
137+ flag.StringVar(&summaryFile, "summary-file", "", "file path to write markdown job summary")
125138
126139 // Split args so the subcommand (first non-flag arg) doesn't block
127140 // flags that appear after it: "pici runner --wait" works.
......@@ -147,6 +160,8 @@ func NewCfg() (*Cfg, string, bool) {
147160 IncludeRunning: includeRunning,
148161 HumanOutput: human,
149162 Wait: wait,
163+ InPlace: inPlace,
164+ SummaryFile: summaryFile,
150165 EnvVars: envVars,
151166 SessionPrefix: sessionPrefix,
152167 }, cmd, wantHelp
......@@ -270,6 +285,8 @@ SUBCOMMAND HELP
270285
271286 FLAGS
272287 -e, -env <KEY=VAL> Set or override environment variable for pico.sh
288+ -in-place Execute workspace in-place without copying to /tmp
289+ -summary-file <path> Write markdown job summary table to file
273290 -pk <path> SSH private key
274291 -ck <path> SSH public certificate key
275292 -artifact-dir <path> Artifact staging directory (default: /tmp/pici-artifacts)
......@@ -571,6 +588,39 @@ func (w *WorkspaceRsync) Checksum() string {
571588 return w.checksum
572589 }
573590
591+type WorkspaceInPlace struct {
592+ Cfg *Cfg
593+ Logger *slog.Logger
594+ Source string
595+ checksum string
596+}
597+
598+func (w *WorkspaceInPlace) Setup() error {
599+ log := w.Logger.With("source", w.Source)
600+ log.Debug("using in-place workspace")
601+ cs, err := computeDirChecksum(w.Source)
602+ if err != nil {
603+ log.Error("compute workspace checksum", "err", err)
604+ } else {
605+ w.checksum = cs
606+ log.Debug("workspace checksum", "checksum", w.checksum)
607+ }
608+ return nil
609+}
610+
611+func (w *WorkspaceInPlace) Cleanup() error {
612+ // In-place workspace is not cleaned up
613+ return nil
614+}
615+
616+func (w *WorkspaceInPlace) GetDir() string {
617+ return w.Source
618+}
619+
620+func (w *WorkspaceInPlace) Checksum() string {
621+ return w.checksum
622+}
623+
574624 func computeDirChecksum(dir string) (string, error) {
575625 hasher := sha256.New()
576626 err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
......@@ -1140,6 +1190,12 @@ func waitAndReport(cfg *Cfg, log *slog.Logger, name, jobID, eventType string) er
11401190 fmt.Fprintf(os.Stdout, "%s job failed: exit %d (%s)\n", icon, exitCode, duration) //nolint:errcheck
11411191 }
11421192
1193+ if cfg.SummaryFile != "" {
1194+ if err := writeSummaryMarkdown(cfg.SummaryFile, name, jobID, jobSessions, known); err != nil {
1195+ log.Error("write summary markdown", "err", err, "file", cfg.SummaryFile)
1196+ }
1197+ }
1198+
11431199 if exitCode != 0 {
11441200 return &JobFailedError{ExitCode: exitCode}
11451201 }
......@@ -1147,6 +1203,74 @@ func waitAndReport(cfg *Cfg, log *slog.Logger, name, jobID, eventType string) er
11471203 return nil
11481204 }
11491205
1206+func writeSummaryMarkdown(filePath string, repoName, jobID string, jobSessions []SessionInfo, known map[string]*sessionState) error {
1207+ if filePath == "" {
1208+ return nil
1209+ }
1210+
1211+ exitCode, status := resolveJobExitCode(jobSessions)
1212+ _, _, duration := computeJobTiming(jobSessions)
1213+ statusIcon := map[string]string{"success": "✅", "failed": "❌"}[status]
1214+
1215+ var sb strings.Builder
1216+ sb.WriteString(fmt.Sprintf("### Pici CI Summary: `%s` (Job `%s`)\n\n", repoName, jobID))
1217+ sb.WriteString(fmt.Sprintf("**Status:** %s %s | **Exit Code:** `%d` | **Duration:** `%s`\n\n", statusIcon, strings.ToUpper(status), exitCode, duration))
1218+
1219+ sb.WriteString("| Session | Status | Exit Code | Duration |\n")
1220+ sb.WriteString("| :--- | :--- | :--- | :--- |\n")
1221+
1222+ for _, s := range jobSessions {
1223+ state := known[s.Short]
1224+ sStatus := "unknown"
1225+ sExit := "—"
1226+ sDur := "—"
1227+ sIcon := "❓"
1228+ if state != nil {
1229+ sStatus = state.status
1230+ sExit = state.exitCode
1231+ if sExit == "" {
1232+ sExit = "—"
1233+ }
1234+ sDur = state.duration
1235+ if sDur == "" {
1236+ sDur = "—"
1237+ }
1238+ sIcon = map[string]string{"running": "🚀", "success": "✅", "failed": "❌"}[sStatus]
1239+ }
1240+ sb.WriteString(fmt.Sprintf("| `%s` | %s %s | `%s` | %s |\n", s.Short, sIcon, sStatus, sExit, sDur))
1241+ }
1242+ sb.WriteString("\n")
1243+
1244+ // Include failed session details
1245+ hasFailed := false
1246+ for _, s := range jobSessions {
1247+ state := known[s.Short]
1248+ if state == nil || state.status != "failed" {
1249+ continue
1250+ }
1251+ if !hasFailed {
1252+ sb.WriteString("### Failed Session Details\n\n")
1253+ hasFailed = true
1254+ }
1255+ history, err := fetchHistoryPlain(s.Name)
1256+ if err != nil {
1257+ history = fmt.Sprintf("(history unavailable: %v)", err)
1258+ }
1259+ sb.WriteString(fmt.Sprintf("<details><summary><b>%s</b> (exit %s)</summary>\n\n```\n%s\n```\n\n</details>\n\n", s.Short, state.exitCode, strings.TrimSpace(history)))
1260+ }
1261+
1262+ f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
1263+ if err != nil {
1264+ return fmt.Errorf("open summary file: %w", err)
1265+ }
1266+ defer f.Close()
1267+
1268+ if _, err := f.WriteString(sb.String()); err != nil {
1269+ return fmt.Errorf("write summary file: %w", err)
1270+ }
1271+ return nil
1272+}
1273+
11501274 // cleanSessionShort produces a readable short name from a full session name.
11511275 // ci.name.jobID.step.lint → lint
11521276 // ci.name.jobID.step.ci.name.jobID.runner → runner
......@@ -2419,11 +2543,20 @@ func runLocal(cfg *Cfg, dest string) error {
24192543 }
24202544 log := logger.With("repo", eventData.Name, "job_id", jobID, "mode", "local")
24212545
2422- // Set up workspace in temp directory
2423- wk := &WorkspaceRsync{
2424- Cfg: cfg,
2425- Logger: log,
2426- Source: cwd,
2546+ // Set up workspace
2547+ var wk Workspace
2548+ if cfg.InPlace {
2549+ wk = &WorkspaceInPlace{
2550+ Cfg: cfg,
2551+ Logger: log,
2552+ Source: cwd,
2553+ }
2554+ } else {
2555+ wk = &WorkspaceRsync{
2556+ Cfg: cfg,
2557+ Logger: log,
2558+ Source: cwd,
2559+ }
24272560 }
24282561
24292562 eng := &JobEngine{
......@@ -2444,7 +2577,11 @@ func runLocal(cfg *Cfg, dest string) error {
24442577 if err := eng.Setup(); err != nil {
24452578 return fmt.Errorf("workspace setup: %w", err)
24462579 }
2447- fmt.Fprintf(os.Stdout, "📦 syncing workspace to temp directory %s\n", eng.Wk.GetDir()) //nolint:errcheck
2580+ if cfg.InPlace {
2581+ fmt.Fprintf(os.Stdout, "📁 using in-place workspace directory %s\n", eng.Wk.GetDir()) //nolint:errcheck
2582+ } else {
2583+ fmt.Fprintf(os.Stdout, "📦 syncing workspace to temp directory %s\n", eng.Wk.GetDir()) //nolint:errcheck
2584+ }
24482585 log.Debug("workspace directory", "dir", eng.Wk.GetDir())
24492586
24502587 // Store event.json in artifact dir
+156, -0
......@@ -1226,3 +1226,159 @@ exit 13
12261226 t.Errorf("expected exit code 13, got %d", jobFailedErr.ExitCode)
12271227 }
12281228 }
1229+
1230+func TestWorkspaceInPlace(t *testing.T) {
1231+ tempDir := t.TempDir()
1232+ testFile := filepath.Join(tempDir, "test.txt")
1233+ if err := os.WriteFile(testFile, []byte("hello world"), 0644); err != nil {
1234+ t.Fatal(err)
1235+ }
1236+
1237+ cfg := &Cfg{
1238+ InPlace: true,
1239+ }
1240+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
1241+ wk := defaultWorkspaceFactory(cfg, logger, tempDir)
1242+
1243+ if _, ok := wk.(*WorkspaceInPlace); !ok {
1244+ t.Fatalf("expected WorkspaceInPlace, got %T", wk)
1245+ }
1246+
1247+ if err := wk.Setup(); err != nil {
1248+ t.Fatalf("Setup failed: %v", err)
1249+ }
1250+
1251+ if wk.GetDir() != tempDir {
1252+ t.Errorf("expected GetDir() == %q, got %q", tempDir, wk.GetDir())
1253+ }
1254+
1255+ if wk.Checksum() == "" {
1256+ t.Errorf("expected non-empty checksum")
1257+ }
1258+
1259+ if err := wk.Cleanup(); err != nil {
1260+ t.Fatalf("Cleanup failed: %v", err)
1261+ }
1262+
1263+ if _, err := os.Stat(testFile); os.IsNotExist(err) {
1264+ t.Fatalf("expected test file to still exist after Cleanup() on in-place workspace")
1265+ }
1266+}
1267+
1268+func TestRunLocal_InPlace(t *testing.T) {
1269+ if _, err := exec.LookPath("zmx"); err != nil {
1270+ t.Skip("zmx not found, skipping test")
1271+ }
1272+
1273+ tempDir := t.TempDir()
1274+ origWd, _ := os.Getwd()
1275+ defer func() { _ = os.Chdir(origWd) }()
1276+ _ = os.Chdir(tempDir)
1277+
1278+ picoContent := `#!/usr/bin/env bash
1279+set -e
1280+echo "inplace marker" > marker.txt
1281+`
1282+ if err := os.WriteFile("pico.sh", []byte(picoContent), 0755); err != nil {
1283+ t.Fatal(err)
1284+ }
1285+
1286+ artifactDir := t.TempDir()
1287+ cfg := &Cfg{
1288+ ArtifactDir: artifactDir,
1289+ MonitorInterval: 100 * time.Millisecond,
1290+ InPlace: true,
1291+ }
1292+
1293+ err := runLocal(cfg, "")
1294+ if err != nil {
1295+ t.Fatalf("expected runLocal in-place to succeed, got %v", err)
1296+ }
1297+
1298+ markerPath := filepath.Join(tempDir, "marker.txt")
1299+ data, err := os.ReadFile(markerPath)
1300+ if err != nil {
1301+ t.Fatalf("expected marker.txt to be created in working directory: %v", err)
1302+ }
1303+ if !strings.Contains(string(data), "inplace marker") {
1304+ t.Errorf("unexpected marker.txt content: %q", string(data))
1305+ }
1306+}
1307+
1308+func TestRunLocal_SummaryFile(t *testing.T) {
1309+ if _, err := exec.LookPath("zmx"); err != nil {
1310+ t.Skip("zmx not found, skipping test")
1311+ }
1312+
1313+ tempDir := t.TempDir()
1314+ origWd, _ := os.Getwd()
1315+ defer func() { _ = os.Chdir(origWd) }()
1316+ _ = os.Chdir(tempDir)
1317+
1318+ picoContent := `#!/usr/bin/env bash
1319+echo "hello from step"
1320+`
1321+ if err := os.WriteFile("pico.sh", []byte(picoContent), 0755); err != nil {
1322+ t.Fatal(err)
1323+ }
1324+
1325+ artifactDir := t.TempDir()
1326+ summaryFilePath := filepath.Join(tempDir, "summary.md")
1327+ cfg := &Cfg{
1328+ ArtifactDir: artifactDir,
1329+ MonitorInterval: 100 * time.Millisecond,
1330+ InPlace: true,
1331+ SummaryFile: summaryFilePath,
1332+ }
1333+
1334+ err := runLocal(cfg, "")
1335+ if err != nil {
1336+ t.Fatalf("expected runLocal to succeed, got %v", err)
1337+ }
1338+
1339+ summaryBytes, err := os.ReadFile(summaryFilePath)
1340+ if err != nil {
1341+ t.Fatalf("expected summary file %s to exist: %v", summaryFilePath, err)
1342+ }
1343+
1344+ summary := string(summaryBytes)
1345+ if !strings.Contains(summary, "### Pici CI Summary:") {
1346+ t.Errorf("expected summary to contain header, got:\n%s", summary)
1347+ }
1348+ if !strings.Contains(summary, "| Session | Status | Exit Code | Duration |") {
1349+ t.Errorf("expected summary to contain table header, got:\n%s", summary)
1350+ }
1351+}
1352+
1353+func TestWriteSummaryMarkdown_WithFailure(t *testing.T) {
1354+ tempFile := filepath.Join(t.TempDir(), "summary.md")
1355+ sessions := []SessionInfo{
1356+ {Short: "setup", Name: "local.repo.job.setup", ExitCode: "0"},
1357+ {Short: "failing-step", Name: "local.repo.job.failing-step", ExitCode: "42"},
1358+ }
1359+ known := map[string]*sessionState{
1360+ "setup": {status: "success", exitCode: "0", duration: "1.2s"},
1361+ "failing-step": {status: "failed", exitCode: "42", duration: "3.4s"},
1362+ }
1363+
1364+ err := writeSummaryMarkdown(tempFile, "test-repo", "job-123", sessions, known)
1365+ if err != nil {
1366+ t.Fatalf("writeSummaryMarkdown failed: %v", err)
1367+ }
1368+
1369+ data, err := os.ReadFile(tempFile)
1370+ if err != nil {
1371+ t.Fatalf("read summary file: %v", err)
1372+ }
1373+ content := string(data)
1374+
1375+ if !strings.Contains(content, "### Pici CI Summary: `test-repo` (Job `job-123`)") {
1376+ t.Errorf("missing header in content:\n%s", content)
1377+ }
1378+ if !strings.Contains(content, "| `failing-step` | ❌ failed | `42` | 3.4s |") {
1379+ t.Errorf("missing table row in content:\n%s", content)
1380+ }
1381+ if !strings.Contains(content, "### Failed Session Details") {
1382+ t.Errorf("missing failed details in content:\n%s", content)
1383+ }
1384+}