@@ -58,8 +58,10 @@ func resolveImportPath(importPath string, workflowPath string) string {
5858// processImportsWithWorkflowSpec processes imports field in frontmatter and replaces local file references
5959// with workflowspec format (owner/repo/path@sha) for all imports found.
6060// Handles both array form and object form (with 'aw' subfield) of the imports field.
61- func processImportsWithWorkflowSpec (content string , workflow * WorkflowSpec , commitSHA string , verbose bool ) (string , error ) {
62- importsLog .Printf ("Processing imports with workflowspec: repo=%s, sha=%s" , workflow .RepoSlug , commitSHA )
61+ // If localWorkflowDir is non-empty, any import path whose file exists under that directory is
62+ // left as a local relative path rather than being rewritten to a cross-repo reference.
63+ func processImportsWithWorkflowSpec (content string , workflow * WorkflowSpec , commitSHA string , localWorkflowDir string , verbose bool ) (string , error ) {
64+ importsLog .Printf ("Processing imports with workflowspec: repo=%s, sha=%s, localWorkflowDir=%s" , workflow .RepoSlug , commitSHA , localWorkflowDir )
6365 if verbose {
6466 fmt .Fprintln (os .Stderr , console .FormatVerboseMessage ("Processing imports field to replace with workflowspec" ))
6567 }
@@ -79,6 +81,10 @@ func processImportsWithWorkflowSpec(content string, workflow *WorkflowSpec, comm
7981 }
8082
8183 // processImportPaths converts a list of raw import paths to workflowspec format.
84+ // Paths that already use the workflowspec format (contain "@") are left unchanged.
85+ // When localWorkflowDir is set, relative paths whose files exist locally are also
86+ // preserved as-is so that consumers who have copied shared files into their own repo
87+ // are not forced onto cross-repo references after every `gh aw update`.
8288 processImportPaths := func (imports []string ) []string {
8389 processed := make ([]string , 0 , len (imports ))
8490 for _ , importPath := range imports {
@@ -87,6 +93,16 @@ func processImportsWithWorkflowSpec(content string, workflow *WorkflowSpec, comm
8793 processed = append (processed , importPath )
8894 continue
8995 }
96+ // Preserve relative paths whose files exist in the local workflow directory.
97+ // Absolute paths (starting with "/") are not checked — they are always resolved
98+ // relative to the repo root and cannot be reliably tested here.
99+ if localWorkflowDir != "" && ! strings .HasPrefix (importPath , "/" ) {
100+ if isLocalFileForUpdate (localWorkflowDir , importPath ) {
101+ importsLog .Printf ("Import path exists locally, preserving relative path: %s" , importPath )
102+ processed = append (processed , importPath )
103+ continue
104+ }
105+ }
90106 resolvedPath := resolveImportPath (importPath , workflow .WorkflowPath )
91107 importsLog .Printf ("Resolved import path: %s -> %s (workflow: %s)" , importPath , resolvedPath , workflow .WorkflowPath )
92108 workflowSpec := buildWorkflowSpecRef (workflow .RepoSlug , resolvedPath , commitSHA , workflow .Version )
@@ -317,10 +333,12 @@ func processIncludesWithWorkflowSpec(content string, workflow *WorkflowSpec, com
317333}
318334
319335// processIncludesInContent processes @include directives in workflow content for update command
320- // and also processes imports field in frontmatter
321- func processIncludesInContent (content string , workflow * WorkflowSpec , commitSHA string , verbose bool ) (string , error ) {
336+ // and also processes imports field in frontmatter.
337+ // If localWorkflowDir is non-empty, any relative import/include path whose file exists under
338+ // that directory is left as-is rather than being rewritten to a cross-repo reference.
339+ func processIncludesInContent (content string , workflow * WorkflowSpec , commitSHA string , localWorkflowDir string , verbose bool ) (string , error ) {
322340 // First process imports field in frontmatter
323- processedImportsContent , err := processImportsWithWorkflowSpec (content , workflow , commitSHA , verbose )
341+ processedImportsContent , err := processImportsWithWorkflowSpec (content , workflow , commitSHA , localWorkflowDir , verbose )
324342 if err != nil {
325343 if verbose {
326344 fmt .Fprintln (os .Stderr , console .FormatWarningMessage (fmt .Sprintf ("Failed to process imports: %v" , err )))
@@ -367,6 +385,15 @@ func processIncludesInContent(content string, workflow *WorkflowSpec, commitSHA
367385 continue
368386 }
369387
388+ // Preserve relative @include paths whose files exist in the local workflow directory.
389+ if localWorkflowDir != "" && ! strings .HasPrefix (filePath , "/" ) {
390+ if isLocalFileForUpdate (localWorkflowDir , filePath ) {
391+ importsLog .Printf ("Include path exists locally, preserving: %s" , filePath )
392+ result .WriteString (line + "\n " )
393+ continue
394+ }
395+ }
396+
370397 // Resolve the file path relative to the workflow file's directory
371398 resolvedPath := resolveImportPath (filePath , workflow .WorkflowPath )
372399
@@ -393,7 +420,28 @@ func processIncludesInContent(content string, workflow *WorkflowSpec, commitSHA
393420 return result .String (), scanner .Err ()
394421}
395422
396- // isWorkflowSpecFormat checks if a path already looks like a workflowspec
423+ // isLocalFileForUpdate returns true when importPath resolves to an existing file
424+ // within localWorkflowDir. The resolved absolute path must stay inside localWorkflowDir
425+ // to guard against path traversal (e.g. "../../etc/passwd" in import paths).
426+ // importPath must be a relative path — callers must not pass absolute paths here.
427+ func isLocalFileForUpdate (localWorkflowDir , importPath string ) bool {
428+ if localWorkflowDir == "" || importPath == "" {
429+ return false
430+ }
431+ localPath := filepath .Join (localWorkflowDir , importPath )
432+ absDir , err1 := filepath .Abs (localWorkflowDir )
433+ absPath , err2 := filepath .Abs (localPath )
434+ if err1 != nil || err2 != nil {
435+ return false
436+ }
437+ // Reject traversal attempts: the resolved path must be a child of localWorkflowDir
438+ if ! strings .HasPrefix (absPath , absDir + string (filepath .Separator )) {
439+ return false
440+ }
441+ _ , statErr := os .Stat (localPath )
442+ return statErr == nil
443+ }
444+
397445// A workflowspec is identified by having an @ version indicator (e.g., owner/repo/path@sha)
398446// Simple paths like "shared/mcp/file.md" are NOT workflowspecs and should be processed
399447func isWorkflowSpecFormat (path string ) bool {
0 commit comments