|
5 | 5 | "encoding/json" |
6 | 6 | "fmt" |
7 | 7 | "os" |
| 8 | + "os/exec" |
8 | 9 | "path/filepath" |
9 | 10 | "strings" |
10 | 11 | "time" |
@@ -88,8 +89,41 @@ func (s *Service) RunMultiRepoAnalysis(ctx context.Context, ws *project.Workspac |
88 | 89 |
|
89 | 90 | runner := NewRunner(s.llmCfg, servicePath) |
90 | 91 |
|
| 92 | + // Incremental mode: check if we can skip or limit analysis |
| 93 | + opts := RunOptions{Workspace: serviceName} |
| 94 | + stateKey := "bootstrap-sha-" + serviceName |
| 95 | + dbPath := filepath.Join(s.basePath, ".taskwing", "memory") |
| 96 | + if store, storeErr := memory.NewSQLiteStore(dbPath); storeErr == nil { |
| 97 | + if state, stateErr := store.GetBootstrapState(stateKey); stateErr == nil && state != nil && state.Checksum != "" { |
| 98 | + headSHA := getGitHEAD(servicePath) |
| 99 | + if headSHA != "" && headSHA == state.Checksum { |
| 100 | + if onProgress != nil { |
| 101 | + onProgress(serviceName, fmt.Sprintf("[%d/%d] no changes", i+1, len(ws.Services))) |
| 102 | + } |
| 103 | + _ = store.Close() |
| 104 | + runner.Close() |
| 105 | + continue |
| 106 | + } |
| 107 | + if headSHA != "" { |
| 108 | + changedFiles := getChangedFilesSince(servicePath, state.Checksum) |
| 109 | + if changedFiles != nil && len(changedFiles) == 0 { |
| 110 | + if onProgress != nil { |
| 111 | + onProgress(serviceName, fmt.Sprintf("[%d/%d] no changes", i+1, len(ws.Services))) |
| 112 | + } |
| 113 | + _ = store.Close() |
| 114 | + runner.Close() |
| 115 | + continue |
| 116 | + } |
| 117 | + if changedFiles != nil && len(changedFiles) < 50 { |
| 118 | + opts.ChangedFiles = changedFiles |
| 119 | + } |
| 120 | + } |
| 121 | + } |
| 122 | + _ = store.Close() |
| 123 | + } |
| 124 | + |
91 | 125 | // Pass workspace (service name) to the runner so agents can tag their findings |
92 | | - results, err := runner.RunWithOptions(ctx, servicePath, RunOptions{Workspace: serviceName}) |
| 126 | + results, err := runner.RunWithOptions(ctx, servicePath, opts) |
93 | 127 | // Close runner immediately after use - NOT deferred in loop! |
94 | 128 | runner.Close() |
95 | 129 |
|
@@ -142,6 +176,18 @@ func (s *Service) RunMultiRepoAnalysis(ctx context.Context, ws *project.Workspac |
142 | 176 | allFindings = append(allFindings, findings...) |
143 | 177 | allRelationships = append(allRelationships, relationships...) |
144 | 178 |
|
| 179 | + // Save git SHA for incremental mode on next run |
| 180 | + if headSHA := getGitHEAD(servicePath); headSHA != "" { |
| 181 | + if store, storeErr := memory.NewSQLiteStore(dbPath); storeErr == nil { |
| 182 | + _ = store.SetBootstrapState(&memory.BootstrapState{ |
| 183 | + Component: stateKey, |
| 184 | + Status: "completed", |
| 185 | + Checksum: headSHA, |
| 186 | + }) |
| 187 | + _ = store.Close() |
| 188 | + } |
| 189 | + } |
| 190 | + |
145 | 191 | if onProgress != nil { |
146 | 192 | onProgress(serviceName, fmt.Sprintf("[%d/%d] done (%d findings)", i+1, len(ws.Services), len(findings))) |
147 | 193 | } |
@@ -454,6 +500,36 @@ func generateReport(projectPath string, results []core.Output, findings []core.F |
454 | 500 | return report |
455 | 501 | } |
456 | 502 |
|
| 503 | +// getGitHEAD returns the current git HEAD SHA for a directory, or empty string if not a git repo. |
| 504 | +func getGitHEAD(dir string) string { |
| 505 | + cmd := exec.Command("git", "rev-parse", "HEAD") |
| 506 | + cmd.Dir = dir |
| 507 | + out, err := cmd.Output() |
| 508 | + if err != nil { |
| 509 | + return "" |
| 510 | + } |
| 511 | + return strings.TrimSpace(string(out)) |
| 512 | +} |
| 513 | + |
| 514 | +// getChangedFilesSince returns files changed between oldSHA and HEAD in the given directory. |
| 515 | +// Returns nil if git operations fail (triggers full bootstrap fallback). |
| 516 | +func getChangedFilesSince(dir, oldSHA string) []string { |
| 517 | + if oldSHA == "" { |
| 518 | + return nil |
| 519 | + } |
| 520 | + cmd := exec.Command("git", "diff", "--name-only", oldSHA+"..HEAD") |
| 521 | + cmd.Dir = dir |
| 522 | + out, err := cmd.Output() |
| 523 | + if err != nil { |
| 524 | + return nil |
| 525 | + } |
| 526 | + raw := strings.TrimSpace(string(out)) |
| 527 | + if raw == "" { |
| 528 | + return []string{} // No changes - empty slice means "nothing changed" |
| 529 | + } |
| 530 | + return strings.Split(raw, "\n") |
| 531 | +} |
| 532 | + |
457 | 533 | func saveReport(path string, report *core.BootstrapReport) error { |
458 | 534 | if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { |
459 | 535 | return fmt.Errorf("create report directory: %w", err) |
|
0 commit comments